Skip to main content
This is the API you call to start background work, steer or stop a job, read and write agent memory, route an inbound message from a channel, and subscribe to live updates. It follows the routes the runtime registers at start-up, so it describes what the server answers on today rather than what older API notes describe. Two services are documented here. The Receipt runtime owns jobs, memory, channel-neutral chat, health and live events. The web app owns the browser-facing /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 port 8787 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.
The runtime routes on this page have no authentication of their own. Nothing at the route layer checks a caller’s identity on the job, memory, chat or health routes — the control is network placement, and the gateway answers a /runtime/* request that did not arrive on a loopback host with 404 Not found. The projection callback is the one exception, and only when you set a shared token for it. Never expose the runtime port directly, and read processes, roles, and routing for the exposure settings that lift that gateway guard before you change one.
/connect is the one runtime prefix the gateway deliberately does not strip. /connect/... arrives at the runtime with its /connect/... path intact, because that is what the runtime’s own /connect routes expect. Do not add /runtime in front of a /connect path. Those routes are the public ones, and they do check identity: they require a Receipt Connect JWT.

Conventions

JSON responses are written with Content-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 the jobs 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? }.
202
boolean
object
The queue job as it now stands.
object
The route never checks that a handler exists for :id. An unknown agent id still gets a 202, and the job sits in the queue forever. A 202 means the job was appended, not that anything will run it — exactly four agent ids have registered handlers: factory, factory-control, factory-monitor and codex. See Jobs, lanes, and durable execution.

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.
202 { 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
202 { ok: true, command } 404 job not found

Read jobs

GET /jobs/:id

Returns the job. When the job is leased 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.
200 { jobs: QueueJob[] }

GET /jobs/:id/wait

Deprecated. This route now answers with Deprecation: true and Link: </jobs/<id>/events>; rel="successor-version". Subscribe to GET /jobs/:id/events instead of long-polling.
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.
200 the job 404 job not found

GET /jobs/:id/events

A server-sent-event stream on the jobs topic, keyed by job id. See Live events.

Memory

Five routes, all POST, all scoped by a :scope path parameter.
Every one of the five memory routes requires an actor context. The route reads actorContext, then authContext, then the top-level body, and that object must carry both a userId and an organizationId. A missing actor surfaces as a 500 Server error, not a 400, because the check runs outside the route’s own error handling.
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
200 { ok: true, decision }, where decision.route is one of:
  • chat — answer directly, with optional contextProviders.
  • organization_skill — draft or save a skill, with operation: "draft" | "save".
  • factory — run durable background work, with requestedProviders and optional action, objectiveMode and probeRouting.
400 { 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 500 Server 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. Neither RECEIPT_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.
This is an invalidation callback, not an append API. It publishes a receipt-append change; it does not write receipts.
string
Optional Bearer $RECEIPT_CALLBACK_TOKEN. A mismatch answers 401 unauthorized.
string
required
The receipt stream the change belongs to.
The body must also resolve an event type, either from 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 a SELECT 1 probe and 503 when it does not.
boolean
boolean
boolean
Always false here too.
number
string
object
Neither payload carries 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.
200 the asset, with 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. The data 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 subscription set is derived server-side from the request’s query parameters rather than sent by the client. When no run or job is selected, job scoping keeps at most 16 related jobs.
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.
The browser client upgrades 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 the agent topic, keyed by the chat stream. Payload is JSON.stringify({ runId, phase, summary }). The server publishes exactly one phase, at the start of a Factory ingress run: processing, with the summary Binding the request to durable objective control.
  • factory-stream-reset — published on the agent topic, keyed by the chat stream. Payload is an HTML fragment.
No other data event is published. In particular, no server code publishes 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.

Diagnostics

The runtime also registers a family of internal diagnostics endpoints. They are token-gated — without a debug token or JWT secret configured they answer 503, and without a bearer token they answer 401 — and several of them mutate live state. They are deliberately not documented here. Next step: write an agent against the TypeScript SDK.