/api/* routes, including Zero sync and receipt ingestion. The Receipt Connect routes under /connect/* are the MCP Gateway’s surface and are covered in the aggregate MCP server; the runtime’s HTML shells and its token-gated diagnostics family are not part of this reference.
Base URL and the gateway prefix
The runtime listens on port8787 by default (PORT overrides it), so direct calls go to http://localhost:8787.
Through the service gateway, the runtime is a private service mounted at the /runtime prefix, and the gateway strips that prefix before forwarding. GET /runtime/healthz at the gateway becomes GET /healthz at the runtime. Prefix every path on this page with /runtime when you go through the gateway.
Conventions
JSON responses are written withContent-Type: application/json; charset=utf-8 and Cache-Control: no-store. Error bodies produced by the plain-text helper are text/plain.
The runtime uses two error shapes, and which one you get depends on the route family. Job and memory routes answer with a bare text string. Chat, Connect and diagnostics routes answer with JSON { ok: false, error: "<snake_case_code>", detail? }.
Four responses come from the framework rather than any individual route:
The server’s idle timeout is 30 seconds.
The runtime does not read or propagate a request id. There is no
x-request-id middleware, and nothing in a runtime response correlates it to a caller’s trace. Request ids exist only in the web app: /api/chat mints one per request and carries it in a single wide log event, and /api/zero/mutate honours an inbound x-request-id. Correlate runtime work by job id and receipt stream instead.Enqueue a job
POST /agents/:id/jobs
Enqueues a job for an agent and returns immediately. Publishes thejobs topic.
string
required
The agent id the job is addressed to.
object
The job payload. Its
kind decides which handler runs it. If no payload key is present, the whole request body is used as the payload, so the route never rejects a body for missing it.string
An explicit job id. If a job with this id already exists it is returned unchanged rather than re-enqueued — this is the enqueue-idempotency contract.
string
default:"collect"
One of
chat, collect, steer, follow_up. All four are accepted.number
default:"2"
Clamped to the range 1–8.
string
Groups jobs for singleton handling.
string
default:"allow"
One of
allow, cancel, steer.object
Object form of the same thing:
{ key?, mode? }.boolean
object
The queue job as it now stands.
object
Control a running job
POST /jobs/:id/steer
Queues a steer command against a job.object
The steer payload. If absent, the whole request body is used as the payload.
string
Who issued the command.
{ ok: true, command }
404 job not found
409 job is <status>; continue through its objective
POST /jobs/:id/follow-up
Same request and response shape as steer, including the same 404 and 409 strings.POST /jobs/:id/abort
string
default:"abort requested"
Recorded with the abort command.
string
{ ok: true, command }
404 job not found
Read jobs
GET /jobs/:id
Returns the job. When the job isleased or running and its lease has already expired, the read also starts a background reconciliation of that job; the response does not wait for it, and a failure there is only logged.
200 the queue job
404 job not found
503 job temporarily unavailable
GET /jobs
string
Filter by job status:
queued, leased, running, completed, failed, canceled.number
default:"50"
Clamped to the range 1–500.
{ jobs: QueueJob[] }
GET /jobs/:id/wait
A long poll that returns when the job reaches a terminal status or the timeout elapses. The route asks the queue for a 200 ms poll interval, but the queue ignores that argument: it waits on queue-snapshot changes and wakes at most 250 ms apart. If the timeout elapses first you still get 200 with the job in its current, non-terminal state.number
default:"15000"
Clamped to the range 0–120000.
job not found
GET /jobs/:id/events
A server-sent-event stream on thejobs topic, keyed by job id. See Live events.
Memory
Five routes, allPOST, all scoped by a :scope path parameter.
object
required
{ userId, organizationId, … }. May be supplied as authContext or as top-level body fields instead.
Every route except
commit appends a memory.accessed receipt recording the operation, the strategy, the actor and the result ids; commit appends memory.committed instead.
Commit answers 400 text required without text. Diff answers 400 fromTs required without fromTs.
Search is keyword matching over stored entry text, and
summarize returns a character-capped join of the matched entries rather than a model-written summary. No embedding dependency is wired into any of the runtime’s memory tool call sites.Channel-neutral chat
These two routes are what the Slack and Teams adapters call. They are channel-neutral: the channel is a field in the body, not a separate endpoint. Both are served on the private runtime network.POST /chat/route
Classification only. It decides how an inbound message should be handled and returns that decision; it starts no objective and calls no provider on the caller’s behalf.object
required
May be supplied as
authContext, or as top-level userId / organizationId.string
required
string
default:"web"
slack or teams. Any other value is treated as web.array
string
string
object
{ ok: true, decision }, where decision.route is one of:
chat— answer directly, with optionalcontextProviders.organization_skill— draft or save a skill, withoperation: "draft" | "save".factory— run durable background work, withrequestedProvidersand optionalaction,objectiveModeandprobeRouting.
{ ok: false, error: "actor_context_required", detail }
409 { ok: false, error: "openai_byok_unavailable", detail: "The organization must configure OpenAI BYOK before semantic chat routing can run." } — the organization must have an OpenAI key on file; there is no platform-key fallback on this path. See Bring your own key.
When routing cannot decide
Routing fails closed, and this is the behaviour most callers get wrong. If the router returns an unusable or incomplete decision, the runtime re-asks once with a verification prompt. If that pass also fails, it raises rather than guessing — it does not default to a background run, and it does not manufacture a provider list from prior turns or prompt keywords. Because the routing call sits outside the route’s own error handling, the caller sees the framework’s 500Server error and the runtime logs http.unhandled_error. The shared user-facing string for the failure is:
Receipt couldn’t determine the access needed for this request. Please retry; no task was started.The Slack app raises that message for any non-
ok response, a missing decision, or a decision it cannot parse. It posts the message in the thread and appends a slack.thread.routing_failed receipt itself, with reason capability_selection_unavailable. The Teams app has not been updated for this and still falls back to a background Factory run. Web chat calls the same router in-process rather than over HTTP, and renders the message inside Beetle stopped before completing this response. — Beetle is the name the interface gives the assistant.
POST /chat/respond
Writes a direct reply for an external channel after/chat/route selected chat. It is the only runtime endpoint that returns prose to Slack and Teams, and it always uses Beetle’s resolved profile prompt.
200 { ok: true, response: { text, profileId } }
400 actor_context_required, or latest_user_text_required when the message text is missing
409 openai_byok_unavailable, with the detail ending “…before Beetle can write a direct chat response.”
502 { ok: false, error: "profile_response_unavailable" } — an empty model answer is an error. The runtime deliberately never substitutes router text or plausible synthetic content for a failed model response.
Projection callback
POST /receipt/callback
The runtime’s projection and invalidation callback. It is where the Resonate driver’s job callbacks land, and where the Postgres receipt store posts each append. NeitherRECEIPT_RESONATE_CALLBACK_URL nor RECEIPT_EVENT_CALLBACK_URL has a default inside the runtime: with both unset, no callback is dispatched at all. The local supervisor script fills them in, setting both to http://127.0.0.1:<runtime port>/receipt/callback, and the deployment templates point them at the runtime service. Either variable satisfies both call sites; they differ only in which one is read first.
string
Optional
Bearer $RECEIPT_CALLBACK_TOKEN. A mismatch answers 401 unauthorized.string
required
The receipt stream the change belongs to.
eventType or from type: "job" plus a reason.
202 { ok: true }
400 stream required, or eventType required when no event type can be resolved
401 unauthorized
Health and readiness
GET /healthz
Always answers 200 — it is a liveness surface.boolean
Always
true in this payload.boolean
boolean
Always
false in this payload — a literal the runtime writes, not a computed signal. Do not build alerting on it.number
string
string
object
A cached queue snapshot:
{ version, total, queued, leased, running, completed, failed, canceled, updatedAt? }. It is cached because, in the code’s words, “Health is a liveness surface, not a replay boundary.”object
{ ok: true, target } or { ok: false, target?, error }, where target is a redacted description of the connection.string
string
GET /readyz
Answers 200 when Postgres responds to aSELECT 1 probe and 503 when it does not.
boolean
boolean
boolean
Always
false here too.number
string
object
jobBackend, checks, workers, stalledObjectives, oldestQueuedMsByLane, lastResumeAt, lastResumeError or watchdog. The runtime exposes no /metrics endpoint of its own.
Static assets
GET /assets/:file
Serves the runtime’s built CSS and JS.string
required
A bare file name. A name containing
.. or / is rejected.Cache-Control: no-cache for .css and .js and public, max-age=3600 for anything else
400 invalid asset path
404 asset not found
Live events
The one thing to get right
Runtime live events are payload-free invalidation signals. A refresh event tells you that something under a topic changed; it does not tell you what. Thedata line carries the stream name for the factory topic when a stream is given, and otherwise a timestamp. Your client must re-fetch the resource it cares about after receiving an event.
Server-sent events
Two SSE endpoints exist.
Both respond with
Content-Type: text/event-stream, Cache-Control: no-store and Connection: keep-alive.
On connect, each subscription immediately emits its topic’s refresh event with data: init, so a client that renders on every event gets a first paint without a special case.
Keepalive frames are sent every 5000 ms:
WebSocket
GET /factory/live upgrades to a WebSocket. Frames are JSON:
The 5000 ms keepalive timer belongs to the SSE path only. A WebSocket connection receives an
init frame per subscription and then nothing until a real event arrives, so run your own connection health check rather than waiting for a ping frame that will not come.http/https to ws/wss, reconnects 1000 ms after a close, and re-dispatches each decoded frame as a DOM event named after the frame’s event. It requires WebSocket and throws when there is none:
Live transport requires WebSocket support.
Topics and refresh events
Every topic except
agent also has a global key, and each publish fans out to it. That is why GET /receipt/stream sees every receipt invalidation rather than one stream’s.
Data events
Refresh events are not the only frames, but they are nearly all of them. Exactly two data events are published by the runtime today, both by the Factory ingress runner:agent-phase— published on theagenttopic, keyed by the chat stream. Payload isJSON.stringify({ runId, phase, summary }). The server publishes exactly one phase, at the start of a Factory ingress run:processing, with the summaryBinding the request to durable objective control.factory-stream-reset— published on theagenttopic, keyed by the chat stream. Payload is an HTML fragment.
agent-token, even though some browser clients still register a listener for it.
The web app API
The web app registers its own routes under/api/* on the public origin — no /runtime prefix, and a different auth model. These are the application’s internal surface, not a versioned public API: treat them as subject to change and prefer the runtime routes above for programmatic work.
/api/zero/query and /api/zero/mutate derive the viewer from the server auth context — the Better Auth session cookie, or a Zero access token the web app issued and verifies itself — never from an unverified client-supplied identity. In practice mutations need the session cookie to reach the web app, which means running zero-cache with ZERO_MUTATE_FORWARD_COOKIES=true so the cookie is forwarded.