Runs
A run is one invocation of an agent — one Claude CLI subprocess spawn, one prompt→reply cycle (or one taskrunner decomposition). Every prompt the portal sends, every Discord mention, every scheduled-job trigger, every chat-bubble message becomes a run.
Runs are append-only after they start: you can list them, read their events, cancel them, retry them. You can't edit them.
There is no
GET /v1/runs/{run_id}. Retrieving a run by id is not a route. The closest things areGET /v1/runs/{run_id}/detail(live registry only — 404s once the run ages out) andGET /v1/runs/{run_id}/events(the durable archive). The list endpoint is the only way to see historical runs. The SDKs'runs.get()targets the missing route and currently fails — see SDK → Runs.
Two stores, two shapes
Runs live in two places, and the API hands you a different object depending on which one you read. This is the single most important thing to understand about this resource.
| Store | What it is | Read it with | Survives a runtime restart? |
|---|---|---|---|
| In-memory registry | The runtime's live Run object for recent runs. |
GET /v1/runs/{run_id}/detail |
No — 404 after it ages out. |
| Events archive | Durable events rows, one per lifecycle step. |
GET /v1/runs (aggregated) and GET /v1/runs/{run_id}/events (raw) |
Yes. |
The list endpoint reconstructs each run by aggregating its event rows — it never reads the registry. So a run that's missing from /detail is still fully present in the list and in its event timeline.
The run-list row
What GET /v1/runs returns, one object per run. Aggregated from the events table.
{
"run_id": "gk3n7q2fzja4bdm6xr8v1cwt5p",
"parent_run_id": null,
"agent": "hachimi",
"transport": "rest",
"started_at": "2026-05-12T10:42:00.123Z",
"last_event_at": "2026-05-12T10:42:34.812Z",
"last_event_type": "run_completed",
"last_status": "ok",
"event_count": 27,
"total_cost_usd": "0.1842",
"model": "claude-opus-4-8",
"reasoning": true,
"model_tier": "heavy",
"model_rule": "agent_default",
"model_enforce": false
}
| Field | Type | Notes |
|---|---|---|
run_id |
string | 26-char lowercase base32 (ULID-style: 48-bit ms timestamp + 80-bit random). No prefix. Time-monotonic when decoded, but not lexically sortable. |
parent_run_id |
string | null | Set when the run was spawned by another run. |
agent |
string | null | Slug of the agent that handled the run. |
transport |
string | Where the run came in: rest, discord, cli, slack, telegram, whatsapp. |
started_at / last_event_at |
timestamp | First and last event row for this run. |
last_event_type |
string | The most recent event's type — how you tell a finished run from an in-flight one. |
last_status |
string | null | Status carried on the last event (e.g. ok). |
event_count |
int | Number of archived event rows. |
total_cost_usd |
string | Summed cost_usd across the run's events. A stringified decimal, USD — not IDR. |
model |
string | null | The model that actually ran. Falls back through the resolver's model_resolved event, then the last claude_iteration, then null. |
reasoning |
bool | null | Whether extended reasoning was on. |
model_tier / model_rule / model_enforce |
Resolver attribution — which tier was picked, by which rule, and whether enforcement was on. |
Note what is not here: there is no prompt, no output, no token counts, no embedded events array. For the prompt and the final reply you need /detail (while it's still in the registry) or the event payloads.
The run-detail object
What GET /v1/runs/{run_id}/detail returns, from the runtime's in-memory registry. This is the whole object — there are no other fields.
{
"run_id": "gk3n7q2fzja4bdm6xr8v1cwt5p",
"customer_id": "internal",
"transport": "rest",
"agent": "hachimi",
"parent_run_id": null,
"status": "succeeded",
"started_at": "2026-05-12T10:42:00.123Z",
"completed_at": "2026-05-12T10:42:34.812Z",
"output": "Pawpado is on prod commit a929cf5 …",
"error": null
}
| Field | Type | Notes |
|---|---|---|
run_id |
string | As above. |
customer_id |
string | The owning tenant. Always the caller's own. |
transport |
string | As above. |
agent |
string | null | |
parent_run_id |
string | null | |
status |
enum | queued, running, succeeded, failed, cancelled. |
started_at / completed_at |
timestamp | null | null until the run starts / finishes. |
output |
string | null | The agent's final reply text. null while running; may be null on failure. |
error |
string | null | Set on failure. |
The run-event row
What GET /v1/runs/{run_id}/events returns. These are events-table rows — not the project-event DTO used by /v1/projects/{id}/events.
{
"id": 918234,
"ts": "2026-05-12T10:42:04.001Z",
"event_type": "tool_call",
"agent": "hachimi",
"transport": "rest",
"duration_ms": 812,
"cost_usd": "0.0021",
"status": "ok",
"payload": { "tool": "bash", "args": { "command": "git log -1" } }
}
| Field | Type | Notes |
|---|---|---|
id |
int | Row id. Not a evt_-prefixed string. |
ts |
timestamp | |
event_type |
string | See the vocabulary below. Underscored, never dotted. |
agent / transport |
string | null | |
duration_ms |
int | null | |
cost_usd |
string | null | Stringified decimal, USD. |
status |
string | null | |
payload |
object | Free-form per event type. |
Event vocabulary
The event types a run actually emits:
run_received · run_started · model_resolved · context_loaded · claude_request · claude_iteration · tool_call · tool_result · run_completed · run_failed · run_cancelled · agent_run_started · user_feedback
A typical successful run: run_received → run_started → model_resolved → context_loaded → then claude_iteration / tool_call / tool_result repeating → run_completed. Failures end in run_failed; cancellations in run_cancelled.
No stream — but there IS push. These event rows are an archive you poll; Catentio does not open a WebSocket or SSE stream. If you want to be notified when a run finishes rather than polling, register an outbound webhook: Webhooks (outbound). (Not to be confused with webhook gates, which are inbound.)
Endpoints
Start a run
POST /v1/runs
This is how you run an agent. It's what the portal's own Invoke button posts to. (POST /v1/agents/{slug}/invoke looks like the obvious alternative, but it is currently broken — see Agents.)
Body:
{
"agent": "hachimi",
"transport": "rest",
"prompt": "What's the status of pawpado's deploy?"
}
| Field | Type | Notes |
|---|---|---|
agent |
string | null | Agent slug. Omit to let the runtime's classifier pick. |
transport |
string | Defaults to "rest". |
prompt |
string | The message. Exactly one of prompt or message is required. |
message |
string | Alternative to prompt — used by conversation-session callers. |
thread_id |
string | null | Conversation identity; routes the run to a conversation session instead of a stateless one-shot. |
channel_id |
string | null | As above. |
override |
object | null | e.g. {"model_override": "<model-id>"} or {"model_override": "auto"}. |
customer_id |
string | null | Accepted but ignored. The tenant comes from your verified session; a client-supplied value is discarded. |
Returns 201:
{ "run_id": "gk3n7q2fzja4bdm6xr8v1cwt5p", "status": "queued" }
The POST returns as soon as the runtime accepts the run — the agent works out-of-band. Poll /events (or /detail) for progress.
Plan caps, not rate limits. There is no per-second HTTP rate limiting and no
X-RateLimit-*headers. What can reject a run is your tier's runs-per-day cap, enforced atPOST /v1/runs: it returns403with aplan_limit_exceededdetail carryingresource,current,limitandtier. Free is 100 runs/day, Starter 5,000/day, Pro unlimited.
List runs
GET /v1/runs
The events-backed listing. Powers Dashboard → Runs and the dashboard's recent-runs strip.
| Param | Type | Notes |
|---|---|---|
agent |
string | Filter to one agent slug. |
transport |
string | Filter to one transport. |
since_hours |
int (1–720) | Restrict to the last N hours. |
limit |
int (1–500) | Page size; default 50. |
offset |
int (≥0) | Pagination offset. |
Pagination is limit/offset. There are no cursors.
{
"data": [ /* run-list rows */ ],
"meta": { "total": 412, "limit": 50, "offset": 0 }
}
const runs = await catentio.runs.list({ agent: 'hachimi', limit: 20 });
runs = catentio.runs.list({"agent": "hachimi", "limit": 20})
runs, err := client.Runs.List(ctx, catentio.Query{"agent": "hachimi", "limit": "20"}, token)
List events for a run
GET /v1/runs/{run_id}/events
The durable event timeline — oldest first. Param: limit (1–2000, default 200).
Use it for auditing a completed run, reconstructing a chat turn sequence, or debugging a failure (the last event before run_failed is usually the culprit). This is also the only way to recover a run that has aged out of the registry.
{
"data": [ /* run-event rows */ ],
"meta": { "limit": 200, "run_id": "gk3n7q2fzja4bdm6xr8v1cwt5p" }
}
Retrieve run detail
GET /v1/runs/{run_id}/detail
The run-detail object above, including output. The chat bubble uses this to pick up the agent's final reply once the run finishes (the run_completed event payload only carries the output length).
Errors: 404 if the run aged out of the runtime's in-memory registry, or if the runtime restarted since the run finished. That is normal, not an error condition — fall back to /events.
Cancel a run
POST /v1/runs/{run_id}/cancel
Stop an in-flight run.
Retry a run
POST /v1/runs/{run_id}/retry
Spawn a fresh run from the original's prompt + agent + transport. Returns 201. The retry is a new run with its own id; the original is untouched.
Errors: 404 if the original is no longer in the registry.
Today's run stats
GET /v1/runs/stats/today
The counters behind the dashboard's hero strip. Despite the name, the window is the last 24 hours, not the calendar day.
{
"runs": 142,
"completed": 138,
"failed": 3,
"in_flight": 1,
"cost_usd": "4.1827"
}
Cost breakdown
GET /v1/runs/costs
Cost per agent, broken down by model tier and model. Param: window — one of 24h, 7d, 30d (default 7d). Any other value is rejected with 422.
{
"data": [
{
"agent": "hachimi",
"total_cost_usd": "2.8140",
"run_count": 89,
"tiers": [
{
"tier": "heavy",
"cost_usd": "2.4010",
"run_count": 61,
"models": [
{ "model": "claude-opus-4-8", "cost_usd": "2.4010", "run_count": 61 }
]
}
]
}
],
"meta": {
"window": "7d",
"window_hours": 168,
"since": "2026-05-05T10:42:00Z",
"total_cost_usd": "4.1827",
"run_count": 142,
"agent_count": 6
}
}
Runs whose model never resolved (legacy paths) bucket as tier unknown rather than being dropped.
Inspect a run's context window
GET /v1/runs/{run_id}/context
GET /v1/runs/{run_id}/context/content
/context categorises what is actually occupying the model's context window, parsed from the session transcript. /context/content returns one page of the raw context text, paginating backwards via before_byte (int, ≥0).
Both 404 (or return an honest "not found" body) when there's no session transcript for the run — one-shot runs on some transports don't produce one.
Not available
GET /v1/runs/{run_id} — does not exist
GET /v1/runs/{run_id}/status — routed, but broken
GET /v1/runs/{run_id} is not a route on the control plane. Nothing serves it.
GET /v1/runs/{run_id}/status is registered on the control plane, but it proxies to a runtime endpoint that doesn't exist, so it fails. Don't build on it; poll /events or /detail instead. It will either be wired up or removed — it is not a supported surface today.
Errors
| Status | Shape | When |
|---|---|---|
| 401 | {"detail": "auth_required"} |
No session presented. |
| 403 | {"detail": {"code": "plan_limit_exceeded", …}} |
Tier cap hit on POST /v1/runs. |
| 404 | {"detail": …} |
Run not in the registry (/detail, /retry), or run id unknown. |
| 422 | FastAPI validation body | Bad param (e.g. an unknown window), or a POST /v1/runs body with neither prompt nor message. |
| 502 | {"detail": "runtime call failed: …"} |
The control plane couldn't reach the runtime. |
Errors are plain FastAPI {"detail": …} bodies. When the failure came from the runtime, the control plane re-raises the runtime's status code and nests its payload under detail.