# Tasks and spend (/docs/agents/tasks-and-spend)

What a task is, how plan caps and spend caps show up as 429s, why a task can wait for your confirmation, and the /v1/tasks endpoints.

codai bills by **task**, not by request. A task is one unit of work — a fresh instruction from a user and everything the agent does to finish it. Continuation turns (tool results, follow-up steps) belong to the task that started them and bill nothing extra.

## When a task starts [#when-a-task-starts]

A request starts a task when it is a top-level turn whose **last message is a real user message** — text a person typed, not a tool result and not an assistant continuation. Rapid follow-ups inside a **10-minute window** after a paid task are free: they count as the same task. A follow-up after the window opens a new one.

Spawned sub-agents and agent-run steps are always continuations (`is_task_start = 0`). A [synchronous agent run](./index.mdx) is exactly one task.

### `X-Codai-No-Task` [#x-codai-no-task]

Utility calls — a title, a classification, a one-line rewrite — should not consume a task. Send `X-Codai-No-Task: 1` on `POST /v1/chat/completions` or `POST /v1/messages` **with `codai-fast` or `codai-explorer`** and the request bills as a continuation. On any other alias the header is `400 bad_request`:

> x-codai-no-task is only valid for utility aliases (codai-fast, codai-explorer); got "codai"

## Caps and the two 429s [#caps-and-the-two-429s]

Before a fresh task runs, the gateway walks your entitlements in this order and stops at the first that pays:

1. Unlimited plan → run.
2. Wallet balance in EUR (welcome credit, referrals, top-ups) → run, charged after the fact.
3. Tasks included in your plan this month (or today, on daily plans) → run.
4. Prepaid task credits → run.
5. Metered overage, if your billing allows it → run.
6. Otherwise **`429 quota_exceeded`**.

```json
{
  "error": {
    "message": "You've used all 5 agent tasks included in the Free plan this month. Your free tasks reset in 7d 3h. Upgrade to Starter for 33 verified tasks every month on the same codai model: https://codai.ro/pricing",
    "type": "rate_limit_error",
    "code": "quota_exceeded"
  }
}
```

`Retry-After` on that response is honest: the number of seconds until the cap resets (next UTC month, or UTC midnight on a daily cap). Continuations are never blocked — a task that already started always finishes.

The **other** 429, `rate_limit_exceeded`, is about request rate and euro budgets, not tasks: per-minute and per-hour request limits (`X-RateLimit-Limit-Minute`, `-Remaining-Minute`, `-Reset-Minute` and the `-Hour` trio on every response) and the daily / monthly EUR spend cap set on a key in the hub. Read `Retry-After` and back off. See [limits and pricing](../gateway/limits-pricing.mdx) for the tiers.

<Callout type="info">
  Both codes carry `type: "rate_limit_error"`, so an OpenAI SDK raises `RateLimitError` for both.
  Distinguish them by `error.code`: `quota_exceeded` means buy or wait for the reset;
  `rate_limit_exceeded` means slow down.
</Callout>

## Outcome billing and confirmation [#outcome-billing-and-confirmation]

Beyond the cap, a task carries an **outcome** — did codai actually solve it? On surfaces that can prove it (desktop, CLI) the client reports `pass` or `fail` with execution evidence and the task settles by itself. Everything else — IDEs, raw SDKs, curl, anything the gateway classifies as `proxy` — cannot prove an outcome, so the gateway asks **you**.

```text
open ──(idle 30 min)──▶ unconfirmed ──(you answer within 24 h)──▶ confirmed | fail
                                    └──(no answer)─────────────▶ window closed, not billed
```

- A `proxy`, `cli` or `mobile` task with no activity for **30 minutes** is closed as `unconfirmed` and given a **24-hour** confirmation window.
- It appears under **Pending** at [hub.codai.ro/tasks](https://hub.codai.ro/tasks) — _"When a task from your IDE or CLI goes idle, it shows up here so you can tell us whether codai solved it."_ You answer **Yes, it worked** or **No**, optionally with a note (kept with the task, never sent to the model).
- **Yes** closes the task as `confirmed` with evidence `user_confirmed` and applies the success charge (€1.20; usage above €1.20 on that task is billed for the excess at standard token rates); on wallet plans the token cost already charged is refunded against it. **No** closes it as `fail` — nothing is charged.
- Let the window lapse and the task is simply not billed as a success.

The hub also shows every task's `surface` (IDE / API, Desktop, CLI, Resolve, Sandbox, Mobile, Hub), request count, upstream cost and what was charged, so you can reconcile a bill line by line.

To attribute tasks precisely, send `X-Codai-Client: <surface>/<version>` (see [request headers](../gateway/headers.mdx#client-identity)) and, if you have your own task ids, `X-Codai-Task-Id` (`[A-Za-z0-9._:-]`, ≤ 128 chars) — the gateway groups requests by it instead of by derived session and echoes it back.

## The task object [#the-task-object]

Every `/v1/tasks` response uses this shape. Money is in integer micro-units; dates are ISO-8601 or `null`.

<TypeTable
  type={{
    id: { type: 'string', description: 'UUID.' },
    surface: {
      type: "'proxy' | 'desktop' | 'cli' | 'resolve' | 'sandbox' | 'mobile' | 'hub'",
      description: 'Where the task came from.',
    },
    session_key: {
      type: 'string | null',
      description: 'Derived session, or your X-Codai-Task-Id.',
    },
    client_task_id: {
      type: 'string | null',
      description: 'Your correlation id when you sent one.',
    },
    title: { type: 'string | null', description: 'First user message, trimmed.' },
    outcome: {
      type: "'open' | 'pass' | 'fail' | 'error' | 'unconfirmed' | 'confirmed' | 'cancelled'",
      description: 'Current state.',
    },
    evidence_kind: {
      type: "'exec_verdict' | 'reexec' | 'user_confirmed' | 'none'",
      description: 'How the outcome was established.',
    },
    evidence_ref: {
      type: 'string | null',
      description: 'Request id or verdict id backing the evidence.',
    },
    requests: { type: 'integer', description: 'Model requests attributed to the task.' },
    cost_micro_usd: { type: 'integer', description: 'Upstream token cost, micro-USD.' },
    charged_micro_eur: { type: 'integer', description: 'What you were charged, micro-EUR.' },
    billed_success: { type: 'boolean', description: 'Whether the flat success charge applied.' },
    billable_at: { type: 'string | null' },
    confirm_deadline_at: {
      type: 'string | null',
      description: 'End of the 24-hour confirmation window for unconfirmed tasks.',
    },
    opened_at: { type: 'string' },
    last_activity_at: { type: 'string' },
    closed_at: { type: 'string | null' },
    note: { type: 'string | null', description: 'Your note from a confirmation.' },
  }}
/>

## Endpoints [#endpoints]

All `/v1/tasks` routes need a key tied to a user account (`401 invalid_api_key` — _Tasks require a user-scoped API key_ — otherwise) and return only your own tasks. They are rate-limited like any other request.

| Method | Path                                | Returns                                                                                                                                                                        |
| ------ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET`  | `/v1/tasks?outcome=&limit=&cursor=` | `{ tasks: Task[], next_cursor: string \| null }`, newest first. `outcome` filters by state; `limit` 1 – 200 (default 50); `cursor` is the `opened_at` of the last row you saw. |
| `GET`  | `/v1/tasks/pending`                 | `{ tasks: Task[] }` — up to 100 tasks waiting for your answer (same list as the hub's **Pending** tab).                                                                        |
| `GET`  | `/v1/tasks/stats?since=`            | `{ since, opened, confirmed, pass, unconfirmed, billed }` counts since an ISO timestamp (default: 30 days ago).                                                                |
| `GET`  | `/v1/tasks/:id`                     | One task, or `404 not_found`.                                                                                                                                                  |
| `POST` | `/v1/tasks/:id/confirm`             | Settle an unconfirmed task from your own code.                                                                                                                                 |

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

```bash
# What is waiting for me?
curl https://ai.codai.ro/v1/tasks/pending \
  -H "Authorization: Bearer $CODAI_API_KEY"

# Confirm one
curl https://ai.codai.ro/v1/tasks/5c1d…/confirm \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "outcome": "confirmed", "note": "Tests green after the patch." }'
```

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

```ts
const headers = {
  Authorization: `Bearer ${process.env.CODAI_API_KEY}`,
  'Content-Type': 'application/json',
};

const { tasks } = (await (
  await fetch('https://ai.codai.ro/v1/tasks/pending', { headers })
).json()) as {
  tasks: Array<{ id: string; title: string | null; confirm_deadline_at: string | null }>;
};

for (const t of tasks) {
  await fetch(`https://ai.codai.ro/v1/tasks/${t.id}/confirm`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ outcome: 'confirmed' }),
  });
}
```

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

```python
headers = {"Authorization": f"Bearer {os.environ['CODAI_API_KEY']}"}

pending = requests.get("https://ai.codai.ro/v1/tasks/pending", headers=headers, timeout=15).json()["tasks"]
for t in pending:
    requests.post(
        f"https://ai.codai.ro/v1/tasks/{t['id']}/confirm",
        headers=headers,
        json={"outcome": "confirmed"},
        timeout=15,
    )
```

</Tab>
</Tabs>

### `POST /v1/tasks/:id/confirm` [#post-v1tasksidconfirm]

Body: `{ "outcome": "confirmed" | "fail", "note"?: string ≤ 500 }`. Response:

```json
{ "taskId": "5c1d…", "outcome": "confirmed", "billed": true, "refundedMicroEur": 118000 }
```

| Status | `code`                 | When                                                                                                |
| ------ | ---------------------- | --------------------------------------------------------------------------------------------------- |
| `400`  | `bad_request`          | Invalid body.                                                                                       |
| `403`  | `forbidden`            | The task belongs to another user.                                                                   |
| `404`  | `not_found`            | Unknown id.                                                                                         |
| `409`  | `task_not_confirmable` | The 24-hour window has closed, or the task is already closed (`Task is already closed as "pass".`). |

Only `open` and `unconfirmed` tasks accept a confirmation. The hub uses the same rules; whichever answers first wins and the other gets `409`.
