Runs
Developer·4 minutes to read
Verified against docs/superpowers/specs/2026-09-15-p4-a-typed-http-api-openapi-webhooks.md §4.3, §4.4, §5.2, §6; re-verified against the code at close-out.
What it does
A run is one piece of work a bot does, from the message that started it to the answer it gives: these endpoints start one, list them, read one and cancel one (F-RUN-1 to F-RUN-9 — durable runs with a checkpoint per step, the triggers that start them, the hash-chained ledger, streaming, budgets, the context budget, the failure states, the blocked state and sub-runs).
Authentication
An organisation API key. GET /runs and GET /runs/{id} need scope read or wider; POST /runs and POST /runs/{id}/cancel need write or wider. A run started here is the key's person sending a message to their own conversation with the bot, so it meets the same access and credit gates the app's composer meets, and it carries the same budget: fifty steps, two euros, ten minutes. A phone's send is the same send, and a script's is too. See Authentication and keys.
Request
POST /runs
<!-- generated:begin POST /runs --> <!-- generated:end POST /runs -->GET /runs
<!-- generated:begin GET /runs --> <!-- generated:end GET /runs -->GET /runs/{id}
<!-- generated:begin GET /runs/{id} --> <!-- generated:end GET /runs/{id} -->POST /runs/{id}/cancel
<!-- generated:begin POST /runs/{id}/cancel --> <!-- generated:end POST /runs/{id}/cancel -->GET /runs takes an optional botId and an optional state. POST /runs takes the bot, the message (1 to 32 000 characters) and an optional untrusted flag; it answers 202 whether the run started at once or the message was queued behind a run already working in that conversation, and the body carries the run either way.
Set untrusted: true when the message body came from somebody else's users rather than from the person the key belongs to. It marks the run tainted from its first token, which removes the capabilities a tainted run may not have and turns its memory and creation writes into cards a human must answer. Leaving it unset says the sender wrote the message, which is what the credential asserts.
POST /runs/{id}/cancel is idempotent: a run that has already finished answers 200 with its row unchanged and emits no event.
curl -sS -X POST "$BOTSEON_API_ORIGIN/api/v1/runs" \
-H "authorization: Bearer $BOTSEON_API_KEY" \
-H "idempotency-key: 2f3a4b5c-6d7e-4f8a-9b0c-1d2e3f4a5b6c" \
-H "content-type: application/json" \
-d '{"botId":"0f3a2b71-6c4d-4a1e-9b2c-7d5e8f0a1b2c","message":"Draft the release notes for v1.4."}'
The same send from Node, through the SDK, waiting for the answer:
import { createClient } from '@botseon/sdk';
const client = createClient({
baseUrl: process.env.BOTSEON_API_ORIGIN ?? '',
apiKey: process.env.BOTSEON_API_KEY ?? '',
});
const result = await client.runTask({
bot: { name: 'Inbox Triage' },
text: 'Draft the release notes for v1.4.',
timeoutMs: 900_000,
});
console.log(result.outcome, result.steps, result.spendMicroCents);
console.log(result.text);
Response
{
"id": "7c1e2d3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
"botId": "0f3a2b71-6c4d-4a1e-9b2c-7d5e8f0a1b2c",
"conversationId": "6b7c8d9e-0f1a-4b2c-8d3e-4f5a6b7c8d9e",
"routineId": null,
"state": "queued",
"trigger": "api",
"taint": false,
"steps": 0,
"spendMicroCents": 0,
"error": null,
"createdAt": "2026-09-18T08:00:00.000Z",
"finishedAt": null
}
state is one of eight: queued, running, waiting_approval, blocked, paused_limit, completed, failed, cancelled. The last three are the terminal ones, and reaching any of them is what sends a run.finished webhook (Webhooks). waiting_approval means a person has to answer something before the run goes on (Approvals); blocked means the bot stopped rather than improvise; paused_limit means a usage limit was reached and the run resumes next cycle. spendMicroCents is an integer in micro-cents.
Errors
| Status | Code | When | What to do |
|---|---|---|---|
| 401 | unauthenticated | The credential is missing, refused, or below the scope the route needs. | Use a write key to start or cancel a run. |
| 404 | not_found | The bot is not visible to this key's person, or it is a team bot whose direct conversation belongs to its owner alone. | Start the run as the bot's owner, or use a bot this person owns. |
| 400 | invalid_request | The message is empty or over 32 000 characters, or the POST carries no Idempotency-Key. | Read details. |
| 402 | payment_required | The organisation needs a trial or a payment. The check runs before any row is written. | Settle the account, then retry. |
| 429 | quota_exceeded | The organisation has made its hour's run starts — 120 by default, and an operator can raise it for one organisation. | Wait for the rolling hour to move, or spread the work out. |
| 429 | rate_limited | A request bucket is full. | Wait for Retry-After. |
| 503 | unavailable | The service could not answer. | Retry with backoff. |
Rate limits
Two one-minute request buckets, 300 per key and 1 000 per organisation, and one product ceiling that is not a request bucket at all: an organisation may create 120 run rows in any rolling hour by default, counted at creation, and a routine's own firings are counted separately. A full bucket answers rate_limited with Retry-After: 60; wait that many seconds and retry. Failed authentication is counted separately, against the caller's address, so a working key's allowance is never spent by somebody else's bad credential. See Envelopes, errors, idempotency and paging.
Last verified against build c0f77aa.