# Agents on live data

Wire an agent as another subscriber: named diffs in, salience-ranked digests out — the narrator registry over a view's own change channel, netted and per-view, on the same store your UI uses.

An agent on Rindle is **another subscriber**. It gets no special plane: it
materializes the same named queries your UI does, receives the same per-commit
deltas, and issues the same writes. This page is the layer that turns those deltas
into **agent-ready context** — named rows, sentence-sized templates, one
append-ready block per commit — **netted, so a confirmed prediction adds no noise,
and scoped to the views the agent watches** — plus the loop discipline for agents
that act on what they see.

```text
subscribe   view.onChanges / useNarration   a view's per-commit FlatChange diffs, netted
resolve     resolveChange(schema, ch)        wire positions → your schema's names
narrate     per-query templates              named rows → salience-tagged prose
digest      digest(events)                   a batch → one salience-ranked block
append      agentContext.append(text)        the prompt prefix never changes
act         predicted mutators               writes → new diffs → the loop continues
```

Everything here is [`@rindle/narrator`](https://github.com/rindle-sh/rindle/tree/main/packages/narrator)
over one supported seam — a view's own change channel (`store.materialize(query,
{ onChanges })`, or `view.onChanges`), no private hooks. [Strut](https://github.com/tantaman/strut/blob/main/src/editor/aiChat.ts)
is a real Rindle app wiring this into a live editor's advisor chat.

## The input: a view's own diffs, netted and named

A materialized view's `onChanges` is the feed — it delivers that **one view's**
per-commit `FlatChange` diffs, with no store-global stream to filter by `qid`. The
wire encoding is positional; `resolveChange` from `@rindle/client` is its pure
inverse: it lifts a change to named row objects using the view's `WireSchema`,
handed to you alongside the changes:

```ts
import { resolveChange, subRow } from "@rindle/client";

store.materialize(unseatedGuestsQuery({ eventId }), {
  onChanges: (changes, phase, schema) => {
    // phase: "snapshot" (initial state) | "batch" (a commit's effects)
    for (const change of changes) {
      const rc = resolveChange(schema, change);
      if (!rc) continue;
      // rc.op: "add" | "remove" | "edit" · rc.row / rc.old: named rows ·
      // rc.aliasChain: the tree level · subRow(rc, "guest"): a correlated sub-row
    }
  },
});
```

Three things the channel does for you:

- **Netted, so a confirmed prediction is silent.** A rebase re-invokes a
  still-pending optimistic write, emitting a balanced `remove`+`add` (or edit
  round-trip) that cancels — so an accurately predicted write narrates *nothing*,
  and only genuine (other-client / mispredicted) change survives.
- **Removed rows carry their subtree.** A bare `remove` carries only the leaving
  row; a view with a narrator attached reconstructs the evicted subtree onto every
  `remove` (client-side, no wire cost), so "who just left this list" resolves
  exactly like an `add` — per view, no global opt-in.
- **A move surfaces as remove+add.** When a row's correlation key changes it leaves
  one parent and joins another; the net only cancels an *identical* pair, so a real
  move survives as `remove`+`add` of the same primary key. Recompose those into an
  `edit` if your agent wants clean move semantics.

## Templates the size of a sentence

The narrator is a registry of **per-query templates**, keyed by `defineQuery` name,
then by op at the root (`add` / `remove` / `edit`) or by count alias for an
aggregate slot. Templates stay small on purpose: the query and relationship
**names already carry the meaning** — `unseatedGuests` + `remove` almost writes
itself. Returning `null` suppresses an event (too ambient to say):

```ts
import type { NarratorRegistry } from "@rindle/narrator";

export const NARRATORS: NarratorRegistry = {
  unseatedGuests: {
    salience: "alert",
    root: {
      add:    ({ sub, context }) => `${sub("guest")?.name} is confirmed but not yet seated for ${context.subject}.`,
      remove: ({ context })      => `A confirmed guest just got a seat for ${context.subject}.`,
    },
  },
  checkInProgress: {
    salience: "info",
    counts: {
      checkedIn: ({ aggregate, context }) => `Check-in for ${context.subject}: ${aggregate?.value} guests in.`,
      issued: () => null, // don't narrate raw issuance churn
    },
  },
};
```

A template receives `{ row, old, parent, sub, aggregate, context }` — the named
rows, a `sub(alias)` resolver for correlated rows, the **exact** aggregate value
(read from the projected count cell, never re-counted), and whatever context you
bound the subscription with (`context.subject` is the conventional human label:
"the Smith wedding"). Salience is one of `alert` / `info` / `ambient`, defaulted
per query and used to rank output.

### Narrate a whole tree

One view can narrate changes at *every* level of its result tree — the root entity
**and** each nested relationship — so an advisor watches the document the user
actually edits, not a flattened shadow of it. Alongside `root`, a `QueryNarrator`
takes `related`, keyed by the relationship's alias:

```ts
export const NARRATORS: NarratorRegistry = {
  deck: {
    salience: "info",
    root:    { edit: ({ row, old }) => (row.name !== old?.name ? `Deck renamed to "${row.name}".` : null) },
    related: {
      slides:     { edit: ({ row, old, parent }) => (row.title !== old?.title ? `Slide "${row.title}" reworded.` : null) },
      components: { salience: "ambient", add: ({ row, parent }) => `Added a ${row.kind} to slide "${parent?.title}".` },
    },
  },
};
```

A nested template gets the same `{ row, old, parent, sub, context }` — where `parent`
is the change's **immediate** container (the deck for a slide, the slide for a
component), and `salience` can be overridden per relationship. Narrating the whole
`deck` view — title, slides, and components — off one subscription keeps an LLM on
the actual document, and that same view already drives your UI: no second query to
maintain.

### Whose change was it?

An advisor usually shouldn't be told about *its own* edits — if AI Arrange moved a
slide, narrating "a slide moved" back to the model is noise. The narrator won't tag
provenance for you, and that's deliberate: "who did this" is domain semantics, so
**model the actor as data** and read it in the template. Give the row a
`last_edited_by` (or `source`) column — your user-edit mutators set it from the
write's principal, your AI-apply mutator sets it to `"ai"` — and branch on it:

```ts
slides: {
  edit: ({ row, old }) =>
    row.last_edited_by === "ai"  ? null                        // the advisor did this — don't echo it back
    : row.doc !== old?.doc        ? `Edited slide "${row.title}".`
    : null,
}
```

Because the actor is a column it rides the ordinary change path — no out-of-band tag
to plumb through the optimistic/rebase machinery, and it's durable, synced, and
queryable (badge "AI-edited" slides in the UI, or narrate them *differently* instead
of suppressing them). One caveat: a hard `remove` carries the leaving row's
*last-edit* actor, not the *delete* actor — if you need "who deleted this," model
deletes as a soft-delete (`deleted_by`) so the delete is itself an actor-stamped edit.

## Wiring: one block per commit

`createNarrator(NARRATORS)` gives you two functions: `narrate(query, schema,
changes, phase, ctx)` renders a batch into `SemanticEvent`s, and `digest(events)`
folds them into a salience-marked block, suppressions dropped. Wire them straight
onto the channel:

```ts
import { createNarrator } from "@rindle/narrator";

const narrator = createNarrator(NARRATORS);
const ctx = { subject: "the Smith wedding" };

store.materialize(unseatedGuestsQuery({ eventId }), {
  onChanges: (changes, phase, schema) => {
    const events = narrator.narrate("unseatedGuests", schema, changes, phase, ctx);
    const block = narrator.digest(events); // salience-ranked; "" when every change was suppressed
    if (block) agentContext.append(block);
  },
});
```

The block reads like this — strongest salience first, suppressions dropped:

```text
⚠️ A confirmed guest just got a seat for the Smith wedding.
• You're seated at Table 4, seat 6.
```

**Why append-only matters.** An agent's context over a view is *hydrate once, then
deltas*. Each commit adds one immutable block; earlier turns are never rewritten.
The prompt prefix therefore never changes, so provider prefix caches stay hot turn
after turn, and the model reads O(change) tokens instead of re-reading O(world) to
find what moved. Because the stream is netted, a turn where the agent's own
prediction was confirmed adds *nothing* — no churn to read past.

### In React: `useNarration`

`@rindle/react` wires the view lifecycle, the change channel, and a buffer for you.
Narration feeds an agent, not the DOM, so the handle is stable and doesn't
re-render; drain it when you prompt:

```tsx
import { useNarration } from "@rindle/react";

const advisor = useNarration(unseatedGuestsQuery({ eventId }), NARRATORS, { ctx });

// each turn, before you prompt the model:
for (const e of advisor.take()) agentContext.append(e.text); // events since your last take()
```

## Observe → act: the reactive loop

Agents that *write* need three rules:

- **Enqueue on observe, act on drain.** The `onChanges` callback only resolves,
  matches, and queues — it never writes. A separate drain issues the writes, so an
  agent can't re-enter the engine mid-dispatch.
- **Dedupe intents, prefer fixers.** Key each intent by `(agent, tree level, row)`
  so a cascade never acts on the same trigger twice — and shape agents as
  *fixers*: an agent that watches an exception query (`unseatedGuests`) and writes
  the fix (a seat assignment) **removes its own trigger**, so the cascade
  terminates naturally and the narrator renders the resolution line for free.
- **An action is just more diffs.** An agent's write flows back as ordinary diffs on
  the views it moved — narrated by the same templates, appended by the same loop.
  There is no special attribution path: a fixer's write reads, in the feed, exactly
  like the change it was fixing.

The cascade is the coordination model: an agent's write produces new diffs, which
wake exactly the agents whose views moved — never a poll, never a broadcast.
Adding an agent is adding a subscriber.

**An LLM in the loop** runs on the same rules with one more: deterministic rules
act synchronously, LLM agents act **asynchronously through proposals**. An intake
agent parses an inbound reply, writes a `pendingProposals` row (that's its whole
act), and a human — or a stricter rule — approves or dismisses it; the model sits
behind a swappable port, so tests drive a stub and the browser can run a local
model. The write that applies a proposal is just another change in the feed.

## Where the agent runs

Everywhere the store runs, unchanged: in the browser beside your UI, in Node, and
against the daemon — an agent process is a **client**: it leases named queries
through your [API authority](/docs/api-server) and subscribes on the same ws plane
a browser does. The engine computes relevance per query, so a fleet of agents
sleeps until data each one is responsible for actually changes.

## Next steps

- [The change model](/docs/change-model) — the four delta shapes underneath
  `FlatChange`, and why a fold reconstructs a fresh query.
- [The browser client](/docs/client) — predicted mutators (the `mutate.*` whose
  effects narrate) and the store seams used here.
- [The API server](/docs/api-server) — the authority an out-of-process agent
  leases its queries through.
- [Strut's advisor chat](https://github.com/tantaman/strut/blob/main/src/editor/aiChat.ts) — a
  real Rindle app; the narration this page describes lands in its live editor advisor chat.
- [Build a synced app](/docs/synced-app-quickstart) — stand up the stack an agent
  subscribes to.

---

[View this page on Rindle](https://rindle.sh/docs/agents)
