Reference

Handler runtime

Edit source

Handler runtime

handler.js is plain JavaScript — a single ES module that default-exports an async function. It runs in a Node worker thread spawned by the runtime, with no DOM, no db proxy, and no access to other apps' data. Everything an automation can do flows through ctx.

The conventional filename is handler.js. The conventional location is <appCodeDir>/automations/<id>/handler.js. Both are constants in automation-manifest.ts (AUTOMATION_HANDLER_FILE, AUTOMATION_MANIFEST_FILE).

The contract

js
export default async ({ ctx, log, input }) => {  // do work via ctx.*  return {    summary: 'one-line summary for the run list',    output: {      /* … */    },  };};
Argument What
ctx The runtime surface — see below.
log log.info, log.warn, log.error. Lines stream into the run's log buffer.
input The trigger's input payload. For webhook fires, the parsed HTTP body. For cron and manual fires, typically empty.

Return shape:

Field Notes
summary A short string. Shown in the desktop's run list as the one-liner under the timestamp. Omit it and the row shows "ok" / "error" only.
output Arbitrary JSON. Persisted on the runs row. If outputSchema is declared on the manifest, validated against it — a mismatch marks the run as error.

A thrown exception (or a rejected Promise) marks the run as error with the thrown message. If onFailure is declared on the manifest, the sibling automation is fired with the failure context.

ctx.tool(name, args)

Call an MCP tool. Returns the tool's result (or throws on failure — there is no built-in retry).

js
const events = await ctx.tool('google-calendar.list_events', {  calendar_id: 'primary',  time_min: new Date().toISOString(),});

Routing: on the desktop, tool calls route through the user's locally-configured MCP servers via a worker bridge. On the gateway, they route through the harness MCP routing (whatever the gateway's OpenClaw integration provides). Both paths land at the same toolDispatcher contract.

Audit: every ctx.tool call is recorded as a run_nodes row tagged kind: "tool" with the tool name, args, ok/error, result, and start/end timestamps. Parallel batches share a batch_id.

Retry is the handler's job. There is no runtime retry. If a tool fails, the call rejects and the handler decides — try/catch and decide whether to retry, log, or fail the run.

ctx.agent({ prompt })

Run one constrained model turn. Returns the model's response.

js
const summary = await ctx.agent({  prompt: `Summarize these calendar events in two sentences:\n\n${JSON.stringify(events)}`,});

The model is picked by requires.model on the manifest ("anthropic/claude-3-5-sonnet", "openai/gpt-4o", etc.). The user's API credentials supply auth; the call is billed to their account.

One turn, no tools. ctx.agent is a single model turn — no tool use, no multi-step loop. If you want the model to call tools, structure your handler as: call ctx.tool to gather data → call ctx.agent to reason over it → call ctx.tool to write the result back. The handler controls the loop; ctx.agent is a primitive inside it.

Audit: every ctx.agent call is recorded as a run_nodes row tagged kind: "agent" with the prompt, response, model id, and token counts when the provider reports them.

ctx.state — cross-run KV

A per-automation key/value store that persists across runs. Backed by the automation_state table in the parent app's runtime.sqlite.

js
const last = await ctx.state.get('last_summary_at');const now = Date.now();if (last && now - last < 60_000) {  return { summary: 'skipped — fired again too soon' };}await ctx.state.set('last_summary_at', now);
Method What
ctx.state.get(key) Returns the JSON-parsed value, or undefined if absent. Falls back to the raw string if the stored value isn't valid JSON.
ctx.state.set(key, value) Persists the value (JSON-serialized). undefined becomes null.
ctx.state.delete(key) Removes the entry. (Aliased as del in some example handlers — delete is canonical.)

State is scoped to the automation id. Two automations in the same app folder don't share state.

ctx.runs — prior-run lookup

Read history of this automation (or another by id).

js
const last = await ctx.runs.last();if (last?.status === 'error') {  log.warn('previous run failed, doing extra validation');} const recentFails = await ctx.runs.list({ status: 'error', limit: 10 });
Method What
ctx.runs.last(filter?) Most recent prior run. Excludes the in-progress current run. Returns undefined if none.
ctx.runs.list(filter?) Up to filter.limit (default 50) prior runs, newest first.

Filter shape:

Field Notes
automationId Defaults to the current automation's id. Set to look at a sibling's history.
status "ok" or "error".
since Epoch ms — only runs newer than this.
limit Default 50.

Each row carries runId, automationId, status, startedAt, endedAt, summary, and output.

ctx.invoke(handle, { input })

Fire another automation as a child run. Returns { output, childRunId }.

js
const { output } = await ctx.invoke('mail/triage-inbox', {  input: { since: lastCheck },});

Handle grammar:

  • <appId>/<automationId> — fully qualified, any app.
  • <automationId> — bare id, resolves to a sibling in the same app folder.

The child run is recorded as a run_nodes row tagged kind: "invoke" on the parent, with the target handle, input, output, and the child's runId. The child run itself shows up in the run list as a normal entry with trigger_origin: "manual" (or however the host tags invoked fires).

ctx.invoke is only wired when the host runtime supplies an invokeDispatcher. Currently the desktop wires it; the cloud gateway plans to. Calling ctx.invoke without a wired dispatcher returns { ok: false, error: 'ctx.invoke is not wired by the host runtime' } — which surfaces as a thrown error in the handler.

onFailure — declarative failure handoff

When the handler throws (or a ctx.tool call rejects and the handler doesn't catch), the runtime checks manifest.onFailure. If set, it fires that sibling automation with the failure context as input:

json
{  "name": "Daily PR digest",  "onFailure": "notify-slack-on-failure",}

The fallback automation receives the failure info as its input and can log to a table, post to Slack via ctx.tool, or escalate to another automation via ctx.invoke. It runs as a separate audited run, not as a child of the failed one.

A complete handler

A "weekly summary" automation that gathers data via ctx.tool, summarizes via ctx.agent, stores via ctx.tool again, and remembers when it last ran via ctx.state:

js
export default async ({ ctx, log }) => {  const last = await ctx.state.get('last_run_at');  const sinceMs = last ?? Date.now() - 7 * 24 * 60 * 60 * 1000;   const entries = await ctx.tool('centraid_read', {    app: 'hydrate',    query: 'list-since',    input: { since: sinceMs },  });   if (!entries.rows.length) {    return { summary: 'no entries this week, skipped' };  }   const summary = await ctx.agent({    prompt: `One short, kind line of encouragement based on this week:\n${JSON.stringify(entries.rows)}`,  });   await ctx.tool('centraid_write', {    app: 'hydrate',    action: 'save-encouragement',    input: { text: summary, week_of: new Date().toISOString() },  });   await ctx.state.set('last_run_at', Date.now());   log.info('summary written');  return {    summary: `wrote: ${summary.slice(0, 50)}…`,    output: { text: summary, rowsConsidered: entries.rows.length },  };};

Note how the handler reaches its own owning app's data through the three-tool dispatcher (centraid_read, centraid_write) — not through a direct db proxy. Automations are clients of their app's tool catalog, same as a UI iframe.

Where to go next

Was this useful?