Apps and handlers

Queries and actions

Edit source

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.jsonqueries[] / 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.jsonqueries[] app.jsonactions[]
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):

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):

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[] and actions[] in app.json. There is no "API documentation" separate from the manifest; the manifest is the documentation.
  • Tool-shaped metadata makes the rest cheap. confirmation hints to a UI whether to prompt. writes/reads drive the change bus. input drives validation. Every field has a job; together they describe a tool.

The word "handler" still shows up where we mean the function body specificallyactions/<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:

ts
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:

js
// 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:

js
// 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 — no stmt.run(), no db.exec(). Use actions/*.js (dispatched via centraid_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:

  1. Looks up the app by app id.
  2. Looks up the handler by action / query name.
  3. Validates input against the handler's input JSON Schema (Ajv, draft 2020-12).
  4. Invokes the handler. The handler receives the validated input as body (actions) or as the query params object (queries).
  5. 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:

ts
 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

Was this useful?