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 are GET /v1/runs/{run_id}/detail (live registry only — 404s once the run ages out) and GET /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_receivedrun_startedmodel_resolvedcontext_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 at POST /v1/runs: it returns 403 with a plan_limit_exceeded detail carrying resource, current, limit and tier. 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.

Next

  • Agents — the roster you name in POST /v1/runs.
  • Projects — the pipeline-driven counterpart for multi-step workflows.
  • Events — the cross-resource event archive.