Apps and handlers
Change stream
Change stream
The change stream is how a Centraid app stays live without polling. It's a single SSE endpoint per app that publishes one event for every successful write.
GET /centraid/<id>/_changesOpen a connection, get a stream of table-level invalidations whenever an action mutates the app's database. Close the connection when you're done.
What lands on the wire
Each event names the table(s) that changed. The subscriber's job is to invalidate any local cache keyed on those tables and re-fetch.
TODO(#120) — capture the exact event payload shape (event name, JSON body, whether it's one event per table or one event with a table list). The README documents the surface but not the wire format; I'd need to read
packages/runtime-core/src/for the exact serialization.
Why table-level, not row-level
Centraid's change bus deliberately publishes table invalidations rather than row diffs. The tradeoff:
- Pro: trivial to implement, trivial to subscribe to, works for arbitrary handler logic (the dispatcher doesn't need to model your business rules).
- Pro: subscribers don't need to merge diffs into client state — they just re-run the query.
- Con: noisier than row diffs. Every change to a 10k-row table wakes every subscriber on that table.
For the kinds of apps Centraid targets — small, single-user, single-purpose — the simpler model wins. A "todos" table doesn't get 10k rows.
Where the invalidation comes from
Two paths produce change events:
- Action handlers. When
centraid_writeinvokes an action, the dispatcher reads thewrites: []declaration from the action's manifest entry and fires invalidations for each table after the handler returns success. centraid_sql_writeagent tool. When an OpenClaw-side agent uses the in-gateway SQL tool to write directly, the plugin's hook emits throughruntime.changeBus, so the stream looks the same to subscribers.
Successful writes also emit through
runtime.changeBus, so any subscriber on/centraid/<appId>/_changeslearns about the mutation. —packages/openclaw-plugin/README.md
What you can't write through
The centraid_sql_* tools refuse DDL (CREATE, ALTER, DROP) and PRAGMA. Schema changes go through migrations/, not the runtime path. The dispatcher's write path only runs action handlers, which are user code — but those handlers run against the scoped DB proxy, which is a small surface (prepare, run, get, all, exec-with-caveats) and is what makes the invalidation tracking reliable.
Subscribing from an app iframe
The pattern is plain EventSource:
// inside app.jsconst es = new EventSource('_changes');es.addEventListener('change', (e) => { const { tables } = JSON.parse(e.data); if (tables.includes('hydrate_daily')) refresh();});TODO(#120) — verify the event name (
changevsmessage) and field name (tablesvstableNames) againstruntime-coresource. Pattern is correct; field names are placeholder.
The path is relative (_changes), so the subscription works whether the gateway is local or remote — the iframe loads from /centraid/<id>/ so _changes resolves to /centraid/<id>/_changes.
Why this matters for the read/write split
Queries are deliberately not tracked. They take a fast read path that doesn't go through SQLite's session-tracking machinery. Writes from a query handler succeed but never show up on _changes — see Queries and actions. The whole reason the read/write split is enforced by a governance directive is to keep the change stream honest.
Where to go next
- Build → UI and changes — practical subscription pattern with cache invalidation.
- Queries and actions — the read/write contract.
- Reference → HTTP API — full endpoint catalog.