Runtime HTTP API
The open-source runtime (@actuate/runtime/worker) serves one actuator over HTTP. This is the API you talk to under actuate dev at http://localhost:8787 and when you self-host. The hosted platform puts its own gateway in front, with MCP, OpenAPI and account authentication.
All responses are JSON unless noted. Trailing slashes are ignored.
Authentication
Section titled “Authentication”Routes are public, run or admin.
- Admin routes require
Authorization: Bearer <ACTUATE_TOKEN>, the owner’s token, and return401 {"error":"unauthorized"}without it. - Run routes (describe the actuator, start a run, read one run) also accept
Authorization: Bearer <ACTUATE_RUN_TOKEN>. That is the token to give your agent. Every other route answers it with403, so an agent can’t approve its own run, answer an input step, fetch approval links, post events, cancel runs or read tables. - Public routes never need a token.
/webhookneeds its own secret, and/approveis protected by the signed token in the link.
The runtime fails closed. If neither ACTUATE_TOKEN nor ACTUATE_RUN_TOKEN is set, every admin and run route answers 503 {"error":"Set ACTUATE_TOKEN to use the admin API."}. If ACTUATE_WEBHOOK_SECRET is not set, /webhook answers 503. Tokens and secrets are compared in constant time.
The only exception is ACTUATE_DEV=1, which actuate dev sets for you: routes whose secret is unset are then open, for local use, but only to requests sent to localhost (or 127.0.0.1, [::1]) that a web page didn’t make. A request through another host, such as a tunnel or a DNS-rebinding page, or one a browser marks cross-site (Sec-Fetch-Site) or cross-origin (Origin), gets 403. That stops a website you have open from starting runs with the real providers in your .dev.vars. To use these routes through a tunnel, set ACTUATE_TOKEN (or ACTUATE_WEBHOOK_SECRET) in .dev.vars. Never set ACTUATE_DEV in production.
Routes
Section titled “Routes”| Method | Path | Auth | What it does |
|---|---|---|---|
GET |
/health |
public | Liveness check |
GET |
/approve?token= |
public | The page behind an approval or input link |
POST |
/approve |
public | Apply an approval decision or an answer |
POST |
/webhook |
webhook secret | Start a run (trigger webhook) |
POST |
/providers/:kind/webhook |
your verifier | A provider webhook, delivered as an event. Only with providerWebhook |
GET |
/ |
run | Describe the actuator |
POST |
/run |
run | Start a run (trigger api) |
GET |
/runs |
admin | List runs |
GET |
/runs/:runId |
run | A run with its steps and logs |
POST |
/runs/:runId/cancel |
admin | Cancel a run |
POST |
/runs/:runId/resume |
admin | Run another pass now |
GET |
/approvals |
admin | Pending approvals |
GET |
/runs/:runId/approvals/:step/links |
admin | The signed approve and reject links of a waiting step |
POST |
/runs/:runId/approvals/:step/approve |
admin | Approve directly |
POST |
/runs/:runId/approvals/:step/reject |
admin | Reject directly |
POST |
/runs/:runId/inputs/:step |
admin | Answer an input step directly |
POST |
/events/:key |
admin | Post an event (GET too, under actuate dev) |
GET |
/tables |
admin | List your tables |
GET |
/tables/:name |
admin | Read a table as JSON or CSV |
Unknown routes return 404 {"error":"not found"}. Unexpected errors return 500 {"error":"<message>"}.
There is no /mcp route: the MCP endpoint is served by the hosted platform’s gateway, not by this runtime. A self-hosted actuator is called over these routes, usually POST /run with ACTUATE_RUN_TOKEN.
The run record
Section titled “The run record”/run, /webhook, /runs and /runs/:runId return run records:
{ "runId": "run_01a1998e44009c41f2e07b3d5a18", "status": "WAITING", "trigger": { "kind": "api" }, "input": { "name": "Aina" }, "output": null, "error": null, "idempotencyKey": "order-1042", "wakeAt": 1793577610000, "passes": 1, "createdAt": 1793577600000, "updatedAt": 1793577600000}| Field | Meaning |
|---|---|
status |
RUNNING, WAITING, COMPLETED, FAILED or CANCELLED |
output |
The value run returned, once COMPLETED |
error |
{ name, message, stack? }, once FAILED |
idempotencyKey |
The Idempotency-Key the run was started with, or null |
wakeAt |
Epoch ms of the next scheduled wake (sleep, timeout, retry), or null |
passes |
How many times run has executed |
createdAt, updatedAt |
Epoch ms |
Health
Section titled “Health”GET /health returns {"ok": true}.
Approve
Section titled “Approve”GET /approve?token=<token> returns the HTML form for the link. It changes nothing.
- An approval link shows the decision (“Approve “manager-ok””), the step’s
message, an optional note box and one button. - An input link (from
waitForInput) shows the prompt and a text box pre-filled withinitial, limited tomaxLength. - An invalid, expired or already used link shows “Link expired”.
Both are sent with Cache-Control: no-store, X-Frame-Options: DENY and a Content-Security-Policy that forbids framing, so the token in the link isn’t cached and the page can’t be embedded in another site.
POST /approve takes a form-encoded body (at most 64 KiB) with token, plus note for an approval or text for an input answer. It records the decision or answer and resumes the run. It returns an HTML page and status 200 on success, or 410 when the token is invalid, expired or already used, or the answer is empty or longer than maxLength. Links are single use: once a step is decided or answered, its links return 410. See Human approvals and input.
Webhook
Section titled “Webhook”POST /webhook starts a run with the JSON body as input and trigger.kind = "webhook". Same validation, responses and Idempotency-Key handling as POST /run.
The request must prove it knows ACTUATE_WEBHOOK_SECRET, or it gets 401. If the secret is not set at all, the route answers 503 (except under ACTUATE_DEV=1). The admin token is not required. Two ways:
- Signature (preferred). Sign the raw body GitHub-style:
X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the body, keyed with the secret>. GitHub does this for you when you set the secret on the repository’s webhook. A signed delivery’s idempotency key is its signature, because headers aren’t signed: a captured request replayed with a newX-GitHub-DeliveryorIdempotency-Keyreturns the original run instead of starting another. - Shared secret. Send it as the
x-actuate-secretheader, or as?secret=for senders that can’t set headers. Query strings end up in proxy and sender logs, so prefer the header, and rotate the secret if a URL leaks.Idempotency-Key, or elseX-GitHub-Delivery, is the idempotency key.
Provider webhooks
Section titled “Provider webhooks”/providers/:kind/webhook (any method) exists only when you pass a providerWebhook function to createRuntime (see Self-hosting). :kind is lowercase letters, digits and hyphens, for example stripe. Your function verifies the request and returns what to do; parseProviderWebhookFromEnv from @actuate/providers does this for the built-in providers:
| Your function | Response |
|---|---|
Returns { event, payload } |
The event is delivered as by POST /events/:key: { "event", "resumed", "buffered" } |
| Returns an array of them | Each is delivered in order: { "events": [ ... ] } |
Returns { reply: "<text>" } |
200 with that text as text/plain, for subscription handshakes such as Meta’s hub.challenge |
Returns null |
{ "ignored": true } |
Throws an error named WebhookVerificationError |
401 {"error": "<message>"} |
The route needs no admin token: verification is your function’s job.
Describe
Section titled “Describe”GET / describes the actuator:
{ "name": "deposit_booking", "description": "Books a slot, sends a payment link, waits for the webhook or reminds.", "capabilities": ["payment", "messaging"], "input": { "type": "object", "properties": { ... }, "required": [ ... ] }, "config": { "type": "object", "properties": { ... } }, "configError": null}input and config are JSON Schema generated from the Zod schemas (input side, so fields with defaults are optional). configError is a message when the configuration is invalid; while it is set, every run fails with a ConfigError.
POST /run starts a run with the JSON body as input and trigger.kind = "api".
| Response | When |
|---|---|
201 + run record |
A new run was started. The record reflects the end of its first pass |
200 + run record |
The Idempotency-Key header matched an existing run; nothing new was started |
400 {"error":"Request body must be JSON."} |
The body is not JSON |
400 {"error":"Invalid input","issues":[...]} |
The body failed the input schema; issues are Zod issues |
The request returns once the first pass ends, so a run that does not wait comes back COMPLETED with its output.
GET /runs lists runs, newest first. Run IDs sort by creation time, so the list is ordered by run ID.
| Query | Default | Meaning |
|---|---|---|
limit |
50 |
1 to 500 |
before |
none | A run ID (exact, newest first by run ID) or an epoch-ms createdAt. Pass the last run’s runId to get the next page |
status |
none | Only runs with this status |
Get a run
Section titled “Get a run”GET /runs/:runId returns the run record plus:
steps: the journal, in order:{ name, status, output, wakeAt, event, attempts, error, updatedAt };logs: up to 500 log lines, in pass order:{ ts, level, message, data }. Each pass keeps at most 100 lines (then one warning line); messages are cut at 2,000 characters, anddatalarger than about 4 KB is replaced by{ "truncated": "<first 4,000 characters of its JSON>" }.
404 if the run does not exist.
Cancel and resume
Section titled “Cancel and resume”POST /runs/:runId/cancel cancels a RUNNING or WAITING run and returns {"cancelled": true}, or {"cancelled": false} if it had already settled. A cancelled run never wakes.
POST /runs/:runId/resume runs another pass immediately and returns the run record. Sleeps, retries and waits that are not due yet suspend again, so it only moves a run forward when what it waits for is already due. It does nothing to settled (COMPLETED, FAILED, CANCELLED) runs.
Approvals
Section titled “Approvals”GET /approvals lists steps waiting for a person, oldest first. kind is "approval" or "input", and message is the approval message or the input prompt:
[{ "runId": "run_...", "step": "manager-ok", "kind": "approval", "message": "Refund RM80 to Aina?", "expiresAt": 1793664000000, "since": 1793577600000 }]GET /runs/:runId/approvals/:step/links returns the signed links for a step that is waiting for a person, for an owner who has no messaging channel. For an approval:
{ "approveUrl": "https://bot.example.com/approve?token=...", "rejectUrl": "https://bot.example.com/approve?token=...", "expiresAt": 1793664000000 }For an input step it returns { "answerUrl": "...", "expiresAt": ... }. To answer directly, POST /runs/:id/inputs/:step with { "text": "..." } (admin auth); it returns {"ok": true, "runId", "step", "approved": true}, or {"ok": false, "reason": "..."} if the step isn’t waiting for an answer, or the answer is empty or longer than maxLength.
It returns 404 if that step is not waiting. The links are never written to run logs, so this route (behind ACTUATE_TOKEN) and the messaging notification are the only ways to get them.
POST /runs/:runId/approvals/:step/approve (or /reject) decides without the signed link. An optional JSON body {"note": "..."} is stored in the result’s note. Returns {"ok": true, "runId", "step", "approved"}, or {"ok": false, "reason"} if the step is not waiting for a decision.
Events
Section titled “Events”POST /events/:key posts an event with the JSON body as its payload, up to 64 KiB of JSON. Every step waiting on key completes with the payload and its run resumes; if none is waiting, the event is buffered for the next wait (for up to 7 days).
{ "resumed": ["run_01a1998e44009c41f2e07b3d5a18"], "buffered": false }- A body that is missing, not JSON, or
nullis delivered as{}. An event is never delivered asnull, sonullfrom a wait always means a timeout. - A larger payload is refused with
500 {"error":"Event payload is too large."}. - If no step is waiting, the event is buffered. At most 1,000 events are buffered per actuator; beyond that the oldest are dropped.
- Under
actuate devonly,GET /events/:keyposts the event too, so the local fake payment provider’s bill links can be opened in a browser. ElsewhereGETanswers404. Forpayment.<referenceId>keys the payload is{"provider":"dev","status":"paid","referenceId":"<referenceId>","via":"GET"}; for other keys it is{"via":"GET"}. - Keys starting with
approval:are reserved; posting one is refused.
The response returns after the resumed runs’ next pass.
Tables
Section titled “Tables”GET /tables lists your tables: ["bookings", "greetings"]. It leaves out the runtime’s own tables (_actuate_*), SQLite’s (sqlite_*), Cloudflare’s (_cf_*) and the one actuate dev adds through Miniflare (__miniflare_*).
GET /tables/:name returns { "columns": [...], "rows": [...] }.
| Query | Default | Meaning |
|---|---|---|
limit |
100 |
1 to 10,000 rows |
offset |
0 |
Rows to skip |
format |
JSON | csv returns a CSV download named <name>.csv. Text cells that start with =, +, -, @, a tab or a carriage return are prefixed with ', so a spreadsheet never runs them as formulas |
An unknown table returns 404 {"error":"Unknown table"}.
Environment variables
Section titled “Environment variables”| Variable | Meaning |
|---|---|
ACTUATE_CONFIG |
JSON object of configuration, validated against the actuator’s config schema. Default {}. Invalid JSON or values set configError and fail every run |
ACTUATE_TOKEN |
The owner’s bearer token: every admin and run route. Keep it for yourself and your dashboards; never give it to an agent. Unset (and ACTUATE_RUN_TOKEN unset too), admin routes answer 503 |
ACTUATE_RUN_TOKEN |
Bearer token for an agent: GET /, POST /run and GET /runs/:runId only. Every other route answers it with 403 |
ACTUATE_WEBHOOK_SECRET |
Secret for POST /webhook: signs the body (X-Hub-Signature-256), or is sent as x-actuate-secret or ?secret=. Required to accept webhooks: unset, /webhook answers 503 |
ACTUATE_APPROVAL_SECRET |
HMAC secret for approval links. If unset, one is generated on first start and kept in the Durable Object’s storage |
ACTUATE_PUBLIC_URL |
Public origin used in approval links, provider callback URLs and the self-call check, e.g. https://bot.example.com. Set it in production. If unset, the origin of the first request made with ACTUATE_TOKEN is used and remembered (under actuate dev, the first request to localhost); until then approval links are relative and a warning is logged. A request from anyone else never sets it, so a forged Host header can’t redirect approval links |
ACTUATE_DEV |
1 only for local development (set by actuate dev): routes whose secret is unset are served open, to localhost requests not made by a web page, instead of answering 503 |
Set the secrets with wrangler secret put rather than in a config file.
Cron schedules do not have an HTTP route. The Worker’s scheduled handler receives each firing and starts a run with input {} and trigger: { kind: "cron", cron: "<expression>" }. If the input schema rejects {}, the firing is skipped and an error is logged.