Build a synced app

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.

View as Markdown

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.

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 over one supported seam — a view’s own change channel (store.materialize(query, { onChanges }), or view.onChanges), no private hooks. Strut 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:

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 meaningunseatedGuests + remove almost writes itself. Returning null suppresses an event (too ambient to say):

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:

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:

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 SemanticEvents, and digest(events) folds them into a salience-marked block, suppressions dropped. Wire them straight onto the channel:

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:

⚠️ 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:

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 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 — the four delta shapes underneath FlatChange, and why a fold reconstructs a fresh query.
  • The browser client — predicted mutators (the mutate.* whose effects narrate) and the store seams used here.
  • The API server — the authority an out-of-process agent leases its queries through.
  • Strut’s advisor chat — a real Rindle app; the narration this page describes lands in its live editor advisor chat.
  • Build a synced app — stand up the stack an agent subscribes to.