Source: https://docs.sogni.ai/api-reference/chat-runs/

# Durable Chat Runs

The durable counterpart to `POST /v1/chat/completions`. Use it when a single chat turn may run long, call multiple tools, hit a safety gate, or need a human to approve spend before it happens. The server persists the run, streams typed events over SSE, and exposes cancel and cost-approval controls.

Run status is one of `queued`, `running`, `completed`, `partial_failure`, `waiting_for_user`, `failed`, or `cancelled`.

### POST /v1/chat/runs

Returns `202 Accepted` with the persisted run. Retrying with the same idempotency key returns that run instead of starting another: `200 OK` with `idempotent: true` once it has left `queued`, or `202` with `idempotent: false` while it is still queued. Reconcile on `data.run.runId`. Fields may be sent in snake\_case or camelCase; unknown fields return `400`. Starting runs too quickly returns `429` with `retryAfter` (seconds) in the body.

### [#](https://docs.sogni.ai/api-reference/chat-runs/#headers)Headers

| Name | Type | In | Description |
| --- | --- | --- | --- |
| Idempotency-Key | string | header | Optional. `X-Idempotency-Key` is also accepted, and the body field `idempotency_key` works as a fallback. Keys longer than 200 characters are ignored, so the request runs as if no key was sent. |

### [#](https://docs.sogni.ai/api-reference/chat-runs/#body)Body

| Name | Type | In | Description |
| --- | --- | --- | --- |
| messagesrequired | array | body | Non-empty OpenAI-style message array. |
| tools | array | body | Custom OpenAI function-tool array. |
| tool\_choice | string | object | body | OpenAI tool choice. |
| model | string | body | Optional LLM model id. |
| sampling | object | body | Optional sampling parameters: `max_tokens` (default 4096), `temperature`, `top_p`, `top_k`, `min_p`, `repetition_penalty`, `frequency_penalty`, `presence_penalty`, `task_profile`, and `think` (default `false`). |
| media\_references | array | body | Optional media references for hosted tools. Each `url` must be an `http://` or `https://` URL; inline `data:` URIs return `400`, as do `data:` image URLs inside `messages`. Upload local files first with the [media URL endpoints](https://docs.sogni.ai/api-reference/media/). |
| media\_context | object | body | Optional initial media context: `images[]`, `videos[]`, `audio[]`, plus `uploadedImages[]`, `uploadedVideos[]`, and `uploadedAudio[]` for caller-supplied uploads. Every entry must be an `http(s)` URL. |
| max\_estimated\_capacity\_units | number | body | Recorded on the request snapshot so callers can show the ceiling alongside the run. Not enforced on chat runs. |
| confirm\_cost | boolean | body | Recorded on the request snapshot. To pause chat runs for cost approval, set `runtime_config.requireJobConfirmation: true`; paid media tool calls then emit `run_awaiting_cost_confirmation` and wait for `confirm-cost`. |
| session\_id | string | body | Optional caller session identifier. |
| client\_message\_id | string | body | Optional caller message identifier for client-side correlation. |
| token\_type | string | body | `spark`, `sogni`, or `auto`. |
| billing\_mode | string | body | `auto` (default), `subscription`, or `tokens`, as on [chat completions](https://docs.sogni.ai/api-reference/chat-completions/). |
| app\_source | string | body | Optional caller label. |
| runtime\_config | object | body | Run-time tuning: `qualityTier` (`fast`, `hq`, or `pro`), `safeContentFilter` (boolean, default `true`; chat runs set the Sensitive Content Filter here, and a top-level `safe_content_filter` returns `400`), and `requireJobConfirmation` (boolean; pause before paid media tool dispatch). Unknown keys are ignored. |

### [#](https://docs.sogni.ai/api-reference/chat-runs/#request)Request

```bash
curl https://api.sogni.ai/v1/chat/runs \
  -H "Authorization: Bearer $SOGNI_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      {"role": "user", "content": "Storyboard a 5-shot neon teaser, 9:16, 15s."}
    ],
    "runtime_config": { "requireJobConfirmation": true }
  }'
```

```javascript
const run = await sogni.chat.runs.create({
  messages: [{ role: 'user', content: 'Storyboard a 5-shot neon teaser, 9:16, 15s.' }],
  runtimeConfig: { requireJobConfirmation: true },
});

const api = 'https://api.sogni.ai/v1/chat/runs';
const headers = { Authorization: `Bearer ${process.env.SOGNI_API_KEY}`, 'Content-Type': 'application/json' };

for await (const event of sogni.chat.runs.streamEvents(run.runId)) {
  if (event.type !== 'run_waiting_for_user' || event.payload?.reason !== 'cost_approval_required') continue;
  const { toolCallId, costApprovalPreview } = event.payload.details;
  // Show costApprovalPreview to the user, then confirm or cancel.
  await fetch(`${api}/${run.runId}/confirm-cost`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ tool_call_id: toolCallId, decision: 'confirm', acceptedCostPreview: costApprovalPreview }),
  });
}
```

```python
import os, requests, uuid

resp = requests.post(
    "https://api.sogni.ai/v1/chat/runs",
    headers={
        "Authorization": f"Bearer {os.environ['SOGNI_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "messages": [{"role": "user", "content": "Storyboard a 5-shot neon teaser, 9:16, 15s."}],
        "runtime_config": {"requireJobConfirmation": True},
    },
)
run = resp.json()["data"]["run"]
```

### [#](https://docs.sogni.ai/api-reference/chat-runs/#response)Response

```json
{
  "status": "success",
  "data": {
    "run": {
      "runId": "run_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
      "ownerWalletAddress": "0x…",
      "status": "queued",
      "schemaVersion": "…",
      "backbone": { "schemaVersion": "…", "modelKnowledgeVersion": "…", "routingPolicyVersion": "…" },
      "timestamps": { "createdAt": "…", "updatedAt": "…" },
      "scope": { "ownerWalletAddress": "0x…", "appSource": "sogni-api" },
      "request": { "…": "request snapshot" },
      "messages": [],
      "toolCalls": [],
      "toolResults": [],
      "mediaContext": { "images": [], "videos": [], "audio": [], "uploadedImages": [], "uploadedVideos": [], "uploadedAudio": [] },
      "events": [{ "sequence": 0, "type": "run_created", "at": "…" }],
      "createTime": 1731950400000,
      "updateTime": 1731950400000
    },
    "idempotent": false
  }
}
```

### GET /v1/chat/runs/:id

Returns the full run snapshot: current status, request, and events. A paused run carries `waiting: { reason, message, details }`; chat runs pause for `cost_approval_required` or `safety_review_required`. Long conversations are trimmed from the oldest turns to fit the model's context window, without an error.

### GET /v1/chat/runs/:id/events

Returns the persisted event log. Pass `?after=<sequence>` to fetch only events past a known sequence number.

### [#](https://docs.sogni.ai/api-reference/chat-runs/#query-parameters)Query parameters

| Name | Type | In | Description |
| --- | --- | --- | --- |
| after | integer | query | Only return events with `sequence > after`. |

### GET /v1/chat/runs/:id/events/stream

Server-Sent Events stream. Replays persisted events, sends a `run_status` frame with the current status, then follows new events until the run reaches `completed`, `failed`, `partial_failure`, or `cancelled`. A `waiting_for_user` run keeps the stream open. Resume with `Last-Event-ID` (takes precedence) or `?after=<sequence>`. Lines starting with `:` are heartbeats. Treat `run_status` frames as idempotent. The log keeps the newest 2,000 events.

### [#](https://docs.sogni.ai/api-reference/chat-runs/#event-types)Event types

-   **Lifecycle:** `run_created`, `run_resumed`, `run_completed`, `run_partial_failure`, `run_failed`, `run_cancelled`
-   **Rounds:** `llm_round_started`, `assistant_message_delta` (each round's full text, not token deltas), `assistant_message_completed`
-   **Tool calls:** `tool_call_dispatched`, `tool_call_progress`, `tool_call_resolved`
-   **Media and state:** `media_context_updated`
-   **Billing:** `llm_spend` carries the authoritative per-round LLM token cost (`costInToken`, `costInUSD`, `tokenType`, `modelName`, and token counts; dedupe on `eventId`), and `billing_preview_updated`
-   **Pause and resume:** `run_waiting_for_user`, `run_awaiting_cost_confirmation` (emits `toolCallId` and an estimate), `run_cost_confirmation_resolved`
-   **Synthetic:** `run_status` frames emitted by the stream on status transitions
-   Ignore event types you don't recognize; new types may be added.

```eventstream
id: 42
event: tool_call_dispatched
data: {"sequence":42,"type":"tool_call_dispatched","at":"…","payload":{…}}

id: 43
event: run_awaiting_cost_confirmation
data: {"sequence":43,"type":"run_awaiting_cost_confirmation","at":"…","payload":{"toolCallId":"call_…","toolName":"generate_video","estimatedCost":120,"tokenType":"spark"}}
```

### POST /v1/chat/runs/:id/cancel

Cooperative cancel for a `queued`, `running`, or `waiting_for_user` run. Flips it to `cancelled`, signals active work, and appends `run_cancelled`. `aborted` reports whether active work was signalled directly. A run that is already finished returns `409`.

### [#](https://docs.sogni.ai/api-reference/chat-runs/#body-1)Body

| Name | Type | In | Description |
| --- | --- | --- | --- |
| reason | string | body | Optional cancellation reason. Defaults to `user_cancelled`. |

### [#](https://docs.sogni.ai/api-reference/chat-runs/#response-1)Response

```json
{
  "status": "success",
  "data": { "run": { "runId": "run_…", "status": "cancelled" }, "aborted": true }
}
```

### POST /v1/chat/runs/:id/confirm-cost

Resolves a run paused with `waiting_for_user` and `cost_approval_required`. `confirm` returns the run to `running` and dispatches the held paid tool calls; `cancel` declines them and ends the run as `completed` with `finalResponse.finishReason: "cancelled"`. One decision applies to every paid call held in the pause.

### [#](https://docs.sogni.ai/api-reference/chat-runs/#body-2)Body

| Name | Type | In | Description |
| --- | --- | --- | --- |
| tool\_call\_idrequired | string | body | ID of the paused tool call (alias `toolCallId`). Use `waiting.details.toolCallId` from the run snapshot or the `run_waiting_for_user` event. |
| decisionrequired | string | body | `"confirm"` or `"cancel"`. |
| acceptedCostPreviewrequired | object | body | Required for `decision: "confirm"` (alias `accepted_cost_preview`). Echo `waiting.details.costApprovalPreview` from the run snapshot or the `run_waiting_for_user` event: `{ totalEstimatedCapacityUnits, tokenType, validityUntil }`. A mismatched value returns `409`; a missing or malformed object returns `400`. The preview expires 5 minutes after the pause; after that, confirm returns `409` and the run can only be cancelled. |
| overrides | object | body | Optional allowlisted edits applied to the tool call named in `tool_call_id` on resume: `qualityTier` (`fast`, `hq`, or `pro`), `safeContentFilter`, and `prompt` or `prompts`. Prompt edits are cost-neutral; cost-inflating keys are silently dropped. |
| reason | string | body | Optional caller-supplied reason recorded with the decision. |
| idempotency\_key | string | body | Optional. The `Idempotency-Key` and `X-Idempotency-Key` headers are also accepted. A concurrent duplicate returns `200` with `idempotent: true`. If the run has already resumed, a retry returns `409`; read the run to reconcile. |

**Some pauses can't be confirmed.** A `cost_approval_required` pause with `waiting.details.flavor: "insufficient_credits"` returns `409` for `confirm`; add credits and start a new run, or send `decision: "cancel"`. A `safety_review_required` pause returns `409` here for either decision; release it with `POST /cancel`.
