Apps and handlers
Queries and actions
Queries and actions
Every action and query is a tool. Not a handler that happens to be invokable. A tool, in the same sense a model uses the word — with a name, a description, a JSON Schema input contract, an output shape, and metadata about its side effects.
Each one lives in two places:
| Where | What's there |
|---|---|
app.json → queries[] / actions[] |
The tool definition: name, description, input schema, output shape, side-effect annotations (reads / writes), confirmation hint. |
queries/<name>.js / actions/<name>.js |
The tool implementation: the function that runs when the tool is invoked. |
The two kinds — query and action — are the two flavors of tool Centraid recognizes. The kind determines who can call it, what timeout it has, whether it's tracked by the change bus, and whether a governance directive forbids writes inside it.
| Query tools | Action tools | |
|---|---|---|
| Declared under | app.json → queries[] |
app.json → actions[] |
| Implementation file | queries/<name>.js |
actions/<name>.js |
| Reached via | centraid_read (generic dispatcher) |
centraid_write (generic dispatcher) |
| Default timeout | 10s | 30s |
| Allowed to write to SQLite | No | Yes |
| Tracked by the change bus | No | Yes (per-table invalidation) |
| Cached on the read path | Yes (handler-runner skips session tracking) | No |
The split is not stylistic — it's load-bearing for the change stream. See why the split is enforced.
A tool, end to end
Here's one tool — the set-cups action — in both halves.
Tool definition (app.json):
{ "name": "set-cups", "description": "Set today's cup count (clamped 0..8).", "confirmation": "none", "input": { "type": "object", "properties": { "cups": { "type": "number", "minimum": 0, "maximum": 8 } }, "required": ["cups"], "additionalProperties": false }, "output": { "type": "object", "properties": { "date": { "type": "string" }, "cups": { "type": "number" }, "goal": { "type": "number" } } }, "writes": ["hydrate_daily"]}Tool implementation (actions/set-cups.js):
export default async ({ body, db }) => { const next = Math.max(0, Math.min(8, Number(body?.cups ?? 0))); await db .prepare( `INSERT INTO hydrate_daily (date, cups) VALUES (?, ?) ON CONFLICT(date) DO UPDATE SET cups = excluded.cups`, ) .run(todayKey(), next); return { status: 200, body: { date: todayKey(), cups: next, goal: 8 } };};Together, those two pieces are the tool. An AI agent introspects the definition through centraid_describe, decides to use it, and calls it via centraid_write. The dispatcher validates input against the schema, runs the implementation, fires the writes:-declared invalidations on /centraid/<id>/_changes. Your UI does the same thing through the same dispatcher.
Why "tool" and not "handler"
Centraid is built so AI agents are first-class callers, alongside your UI. Modeling each capability as a tool — not as a private function that "can also be reached from outside" — is what makes that work:
- One mental model for everything that can call your app. The agent and your iframe see the same surface.
- The manifest is the contract. Anyone — human, agent, CLI — discovers what an app can do by reading
queries[]andactions[]inapp.json. There is no "API documentation" separate from the manifest; the manifest is the documentation. - Tool-shaped metadata makes the rest cheap.
confirmationhints to a UI whether to prompt.writes/readsdrive the change bus.inputdrives validation. Every field has a job; together they describe a tool.
The word "handler" still shows up where we mean the function body specifically — actions/<name>.js is "the handler that implements the <name> action tool". When we mean the capability, we say tool.
The handler surface
Both kinds receive the same arguments:
type HandlerArgs = { db: ScopedDb; // proxied SQLite, scoped to this app's data.sqlite log: ScopedLog; // structured logger app: AppRef; // { id, version, … } ctx: { fetch: typeof fetch }; // timeout-bound fetch — no fs, no child_process, no env};A query:
// queries/get-today.jsexport default async ({ db }) => { const row = await db.prepare('SELECT cups FROM hydrate_daily WHERE date = ?').get(todayKey()); return { date: todayKey(), cups: row ? row.cups : 0, goal: 8 };};An action:
// actions/set-cups.jsexport default async ({ body, db }) => { const next = Math.max(0, Math.min(8, Number(body?.cups ?? 0))); await db .prepare( `INSERT INTO hydrate_daily (date, cups) VALUES (?, ?) ON CONFLICT(date) DO UPDATE SET cups = excluded.cups`, ) .run(todayKey(), next); return { status: 200, body: { date: todayKey(), cups: next, goal: 8 } };};The action receives a body argument; the query reads its parameters from the validated input (the dispatcher passes whichever the handler kind expects).
All db.* calls are async
Every call to the scoped DB proxy round-trips to the host process — the worker never sees the raw connection. .get(), .all(), .run() all return Promises.
Always
await. Forgetting it is the #1 handler bug. —packages/openclaw-plugin/README.md
Why the read/write split is enforced
A .run() inside a query handler will succeed. It mutates the database. But the gateway's handler runner takes a perf shortcut on the read path: it skips SQLite session tracking for handlerKind === 'query'. Writes from a query are therefore invisible to the change bus. Subscribers on /centraid/<id>/_changes never learn the data moved. The iframe stays stale. No error fires.
This is the silent class of bug the directive query-handlers-read-only exists to prevent. From the constitution:
centraid query handlers (
*/queries/*.js) must not mutate the database — nostmt.run(), nodb.exec(). Useactions/*.js(dispatched viacentraid_write/POST /centraid/_tool/centraid_write) for any writes. —CONSTITUTION.md
The pre-commit hook scans changed queries/*.js files; CI re-checks. Per-line waiver // governance: allow-query-handlers-read-only <reason> exists for rare opt-in cases (e.g. lazy view materialization on first access).
Dispatcher validation (Ajv)
When a tool call lands, the dispatcher:
- Looks up the app by
appid. - Looks up the handler by
action/queryname. - Validates
inputagainst the handler'sinputJSON Schema (Ajv, draft 2020-12). - Invokes the handler. The handler receives the validated input as
body(actions) or as the query params object (queries). - Returns the handler's result — for actions, the dispatcher also fires the corresponding
writes:table invalidations.
Schema violations come back as MCP-shaped isError: true envelopes with { code: "INVALID_INPUT", message, path }. See Reference → error codes for the full table.
TypeScript authoring
Both kinds export types you can satisfies:
export default (async ({ db }) => { return await db.prepare('SELECT * FROM tasks WHERE done = 0').all();}) satisfies QueryHandler;The plugin loads .js files only at runtime. Author in TypeScript, ship the compiled .js next to it. See Authoring apps in TypeScript for the recommended workflow.
What you can't do from a handler
By design, the worker surface is small:
- No
fs. Handlers can't read the gateway's filesystem. - No
child_process. Can't shell out. - No
process.env. Can't read environment. - No raw network beyond
ctx.fetch. Timeout-bound; no raw sockets.
The narrowness is the point — every escape hatch you add becomes a thing every reviewer has to think about. Today, a handler is purely "input → SQL → output, optionally with fetch". That's a small surface to audit.
Where to go next
- Change stream — the SSE feed that makes the read/write split visible.
- Build → Queries and Actions — practical walkthroughs.
- Reference → Three-tool dispatcher — the call shapes.