An application agent can observe live query changes and use them as model input.
@rindle/narrator converts those changes into text through templates that you define.
Rindle does not select a model, manage its context, or decide which actions it can perform.
For instructions for a coding agent working on your app, read For coding agents instead.
Start with a known query
This example continues the fragment example.
It uses that guide’s shared/fragments.ts, which defines issueCardsQuery, schema,
and the issue and comment tables.
The Node demonstration creates its own store. It does not import the browser startup module.
Install the narrator in that project:
pnpm add @rindle/narrator
Define templates for the query’s root and its comments relationship:
// shared/narrators.ts
import type { NarratorRegistry } from "@rindle/narrator";
export const narrators = {
issueCards: {
salience: "info",
root: {
add: ({ row }) => `Issue ${JSON.stringify(row.title)} entered the visible list.`,
remove: ({ row }) => `Issue ${JSON.stringify(row.id)} left the visible list.`,
edit: ({ row, old }) => row.title === old?.title
? null
: `Issue ${JSON.stringify(row.id)} is now titled ${JSON.stringify(row.title)}.`,
},
related: {
comments: {
salience: "ambient",
add: ({ row }) => `Comment ${JSON.stringify(row.body)} entered the issue.`,
remove: ({ row }) => `Comment ${JSON.stringify(row.id)} left the issue.`,
edit: ({ row, old }) => row.body === old?.body
? null
: `Comment ${JSON.stringify(row.id)} changed to ${JSON.stringify(row.body)}.`,
},
},
},
} satisfies NarratorRegistry;
The registry key issueCards matches the name passed to defineQuery.
The relationship key comments matches the alias passed to .sub().
A template returns a string, or null to suppress that event.
Templates describe changes to query results. A row leaving a filtered or limited query does not necessarily mean that someone deleted it. That distinction matters when an agent decides what happened.
Capture a snapshot, then changes
A view’s onChanges callback receives its changes, delivery phase, and wire schema.
narrate resolves positional rows to named fields and applies the templates.
digest formats the resulting events, ordered by salience: alert, info, then ambient.
Create this Node program:
// scripts/narration-demo.ts
import { createWasmStore } from "@rindle/wasm";
import { createNarrator } from "@rindle/narrator";
import type { SemanticEvent } from "@rindle/narrator";
import { issueCardsQuery, schema } from "../shared/fragments.ts";
import { narrators } from "../shared/narrators.ts";
const store = await createWasmStore(schema);
const initialComment = { id: "c1", issueId: "i1", body: "Add a screenshot." };
await store.write((tx) => {
tx.add("issue", { id: "i1", title: "Ship the example" });
tx.add("comment", initialComment);
});
const narrator = createNarrator(narrators);
let pending: SemanticEvent[] = [];
const view = store.materialize(issueCardsQuery(), {
onChanges: (changes, phase, wireSchema) => {
if (phase !== "batch") return;
pending.push(...narrator.narrate("issueCards", wireSchema, changes, phase));
},
});
try {
// This local store is already complete. A synced client must establish readiness first.
const context: Array<{ role: "user"; content: string }> = [{
role: "user",
content: `Current query result:\n${JSON.stringify(view.data)}`,
}];
await store.write((tx) => {
tx.edit("comment", initialComment, {
...initialComment, body: "The screenshot is ready.",
});
});
const block = narrator.digest(pending);
pending = [];
if (block.length > 0) context.push({ role: "user", content: `Data changes:\n${block}` });
console.log(JSON.stringify(context, null, 2));
} finally {
view.destroy();
}
Run it with Node 22.18 or later:
node scripts/narration-demo.ts
The output contains an initial query result and a text description of the comment edit.
The program does not call a model or perform an agent action.
The context array illustrates input that your application can pass to its model integration.
The callback only collects events. The application drains them after the write finishes. Use the same separation for an agent that writes: observe, queue work, then act outside the callback.
Understand the events
| Template field | Meaning |
|---|---|
row |
The changed row, with named fields |
old |
The previous row for an edit |
parent |
The immediate containing row for a nested change |
sub(alias) |
A correlated sub-row available on an add |
aggregate |
The alias and projected value for a count change |
context |
Application context passed to narrate |
related accepts an alias such as comments, or a full dotted alias path for a deeper relationship.
A dotted key takes precedence over a matching leaf alias.
counts maps a countAs alias to its template.
Use resolveChange from @rindle/client when you need named changes without text templates.
A view combines changes that cancel within one delivery. An accurate optimistic write can produce an event when the prediction first changes the view. Its later confirmation produces no additional event if the visible result stays unchanged. Narration is a result-change feed, not an audit log or mutation acknowledgement channel.
The narrator does not identify the actor automatically. If attribution matters, store a trusted actor field with the application write. A deleted row’s last-edit actor does not identify who deleted it. Use an audit record or an explicit soft-delete field for that distinction.
Buffer narration in React
The optional React package manages a dedicated narration view and event buffer:
pnpm add @rindle/narrator-react
Add this component under the fragment example’s existing Rindle provider:
// src/NarrationPreview.tsx
import { useState } from "react";
import { createNarrator } from "@rindle/narrator";
import { useNarration } from "@rindle/narrator-react";
import { issueCardsQuery } from "../shared/fragments.ts";
import { narrators } from "../shared/narrators.ts";
const narrator = createNarrator(narrators);
export function NarrationPreview() {
const [text, setText] = useState("");
const buffer = useNarration(issueCardsQuery(), narrators, {
phases: ["batch"],
max: 200,
});
return (
<section>
<button onClick={() => setText(narrator.digest(buffer.take()))}>
Show changes since the last click
</button>
<pre>{text}</pre>
</section>
);
}
The handle stays stable and does not cause a render for each event.
take() returns and clears the events. clear() discards them.
The default phase is batch, so initial snapshot rows do not appear as new changes.
Add snapshot to phases to include them.
The default buffer limit is 200 events. Overflow discards the oldest events.
A changed query or registry, or an unmount, also clears the buffer.
Keep the registry at module scope to avoid resubscribing on each render.
An unnamed query needs as in the options to identify its registry entry.
The hook creates its own materialized view and destroys it on cleanup.
It does not reuse another component’s useQuery view.
Connect an agent deliberately
Before sending a snapshot from a synced client, establish the readiness your task needs. A partial local result is not a complete server result. For a long-running agent, retain its query and manage reconnects, errors, and context size.
Treat row text as application data in the model input. Use explicit action policies and validated mutators for writes. A model response does not become an authorized mutation by appearing in this feed. Deduplicate scheduled actions and prevent the agent from repeatedly responding to its own changes.
The narrator formats JavaScript view changes. It does not require React, a model SDK, or a particular storage backend. The chosen store still determines runtime support, synchronization, and deployment requirements. See client setup, mutators, and testing for those parts.