# Async runs (/docs/agents/async-runs)

Create a durable run, poll it, read its steps, follow it over SSE, cancel it.

An asynchronous run is persisted the moment you create it. The HTTP request returns at once; the loop executes on the gateway and survives your client going away. You then poll, stream or cancel by id.

Async runs need a key that belongs to a codai account. Legacy compat keys get `403 forbidden` — *Async agent runs require an authenticated codai account key.* — and can use the synchronous endpoint only.

## Lifecycle [#lifecycle]

```text
queued ──▶ running ──▶ completed
                  ├──▶ failed
                  └──▶ cancelled
```

`status` is one of `queued`, `running`, `completed`, `failed`, `cancelled`. A run that stops before finishing carries `error`; one that finishes carries `result`.

<Steps>
<Step>

### Create [#create]

`POST /v1/agents/runs` takes the same body as a [sync run](./index.mdx#request-body) plus two fields:

<TypeTable
  type={{
    budget_seconds: { type: 'integer', description: 'Wall-clock hint for the run, 10 – 1800. Stored on the run; the loop applies its own budget today.', default: '300' },
    client_request_id: { type: 'string', description: 'Idempotency key, up to 200 characters, scoped to your account. A retry with the same value returns the existing run instead of creating a second one.' },
  }}
/>

<Tabs items={['curl', 'TypeScript', 'Python']}>
<Tab value="curl">

```bash
curl https://ai.codai.ro/v1/agents/runs \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "task": "Audit the public API of github.com/codai-ro/codai-protocol and list every breaking change since v0.9.",
    "budget_seconds": 600,
    "client_request_id": "audit-protocol-2026-09-23"
  }'
```

</Tab>
<Tab value="TypeScript">

```ts
const res = await fetch('https://ai.codai.ro/v1/agents/runs', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CODAI_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    task: 'Audit the public API of github.com/codai-ro/codai-protocol and list every breaking change since v0.9.',
    budget_seconds: 600,
    client_request_id: 'audit-protocol-2026-09-23',
  }),
});
const { id, status, poll } = (await res.json()) as { id: string; status: string; poll: string };
```

</Tab>
<Tab value="Python">

```python
import os, requests

r = requests.post(
    "https://ai.codai.ro/v1/agents/runs",
    headers={"Authorization": f"Bearer {os.environ['CODAI_API_KEY']}"},
    json={
        "task": "Audit the public API of github.com/codai-ro/codai-protocol and list every breaking change since v0.9.",
        "budget_seconds": 600,
        "client_request_id": "audit-protocol-2026-09-23",
    },
    timeout=30,
)
run = r.json()  # {"id": "...", "status": "queued", "poll": "/v1/agents/runs/..."}
```

</Tab>
</Tabs>

Response is `202 Accepted`:

```json
{ "id": "3f9a1c2e-…", "status": "queued", "poll": "/v1/agents/runs/3f9a1c2e-…" }
```

A duplicate `client_request_id` also answers `202`, with the original `id`.

</Step>
<Step>

### Poll [#poll]

`GET /v1/agents/runs/:id` — owner-scoped; someone else's id is `404 not_found`.

```json
{
  "id": "3f9a1c2e-…",
  "status": "completed",
  "task": "Audit the public API of …",
  "model": "codai",
  "result": "Two breaking changes since v0.9: …",
  "error": null,
  "step_count": 0,
  "usage": { "prompt_tokens": 41230, "completion_tokens": 1874 },
  "created_at": "2026-09-23T09:12:04.118Z",
  "started_at": "2026-09-23T09:12:04.402Z",
  "finished_at": "2026-09-23T09:13:51.977Z"
}
```

Poll every few seconds; the loop typically finishes in tens of seconds to a few minutes. Terminal states are `completed`, `failed`, `cancelled`.

</Step>
<Step>

### Read the steps [#read-the-steps]

`GET /v1/agents/runs/:id/steps` returns `{ id, steps: [...] }`, newest first. Each step:

<TypeTable
  type={{
    seq: { type: 'integer', description: 'Position in the run, from 0.' },
    kind: { type: "'plan' | 'tool_call' | 'observation' | 'answer'", description: 'What the step records.' },
    summary: { type: 'string', description: 'Short text — for an answer step, the first 2 000 characters of the result.' },
    detail: { type: 'object | null', description: 'Structured payload when the step has one.' },
    latencyMs: { type: 'integer | null', description: 'Time spent on the step.' },
    createdAt: { type: 'string', description: 'ISO timestamp.' },
  }}
/>

<Callout type="info">
Today the gateway records one `answer` step when the run completes. Intermediate `plan` / `tool_call` / `observation` steps are part of the schema and will appear as the loop starts persisting them — read `kind` rather than assuming a fixed sequence.
</Callout>

</Step>
<Step>

### Stream [#stream]

`GET /v1/agents/runs/:id/stream` is Server-Sent Events. Send `Accept: text/event-stream` and read until `done`:

```bash
curl -N https://ai.codai.ro/v1/agents/runs/3f9a1c2e-…/stream \
  -H "Authorization: Bearer $CODAI_API_KEY"
```

```text
event: step
data: {"seq":0,"kind":"answer","summary":"Two breaking changes since v0.9: …"}

event: done
data: {"status":"completed","result":"Two breaking changes since v0.9: …","error":null,"step_count":0}
```

| Event | Payload | Meaning |
| --- | --- | --- |
| `step` | `{ seq, kind, summary }` | One step, in order. Every step recorded so far is replayed when you connect. |
| `done` | `{ status, result, error, step_count }` | The run reached a terminal state. The stream ends. |
| `timeout` | `{}` | You have been connected for 30 minutes. The stream ends; reconnect if the run is still going. |

**Reconnecting.** Frames carry no `id:` line and the gateway does not read `Last-Event-ID`. To resume, simply connect again: all steps are replayed from `seq 0`, so keep the highest `seq` you have seen and skip anything at or below it. The stream polls the run once per second, so a reconnect costs at most a second of latency.

The stream is `text/event-stream; charset=utf-8` with `Cache-Control: no-cache` and `x-accel-buffering: no`, so it passes through nginx-style proxies unbuffered. Use `curl -N` or a non-buffering client.

</Step>
<Step>

### Cancel [#cancel]

`POST /v1/agents/runs/:id/cancel` — only a `queued` or `running` run can be cancelled:

```json
{ "id": "3f9a1c2e-…", "status": "cancelled" }
```

Anything else — already finished, already cancelled, not yours — is `404 not_found` (*Agent run not found or not cancellable.*). The in-flight loop is not interrupted mid-tool, but its result is discarded when it tries to complete, and the run stays `cancelled`.

</Step>
</Steps>

## Stats [#stats]

`GET /v1/agents/runs/stats?days=7` — your runs over the last 1 – 90 days (default 7):

```json
{
  "window_days": 7,
  "total": 42,
  "completed": 38,
  "failed": 3,
  "cancelled": 1,
  "in_flight": 0,
  "prompt_tokens": 1830412,
  "completion_tokens": 78210,
  "avg_steps": 0,
  "avg_latency_ms": 64211
}
```

## Durability [#durability]

A run whose gateway instance restarts mid-flight is left `running` with a short lease. A sweeper reclaims runs whose lease lapsed and executes them again from the stored task, so the run always reaches a terminal state without you resubmitting. Because the re-execution starts over rather than resuming a checkpoint, make sure the task is safe to run twice — reads are; unguarded side effects are not.

## Errors [#errors]

| Status | `code` | When |
| --- | --- | --- |
| `400` | `bad_request` | Invalid body; `error.details` lists the fields. |
| `403` | `forbidden` | Plan without sub-agents/tools, or a legacy key on the async endpoints. |
| `404` | `not_found` | Unknown id, not your run, or cancel on a finished run. |
