# Twigg quickstart

Twigg is a context store, assembler and model router in one. Your application
keeps the agent loop and the tools. Twigg keeps the context, fits it to the
model's window, translates it for the provider you pick per turn, streams the
answer back, and records what it sent and what it cost.

Base URL: `https://api.twigg.ai`. Every endpoint below is under `/api/v1` and
takes one API key as a bearer token. Keys look like `tw_live_…` and are created
in the console at https://twigg.ai/dashboard/api-keys.

There is no SDK. An HTTP client that can read server-sent events is the whole
integration.

For system prompts, saved tools, context budgets and retention settings, see [Context control](https://twigg.ai/docs/context#configure-prompts-tools-and-context).

## 1. Create a chat, once


```bash
curl -X POST 'https://api.twigg.ai/api/v1/chats' \
  -H "Authorization: Bearer $TWIGG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "namespace": "acme/proj-7",
  "title": "Support thread"
}'
```



```json
{
  "id": "01912d4e-9c1a-7b3f-8a21-6f1e2c3d4b5a",
  "namespace": "acme/proj-7",
  "title": "Support thread",
  "description": null,
  "user_metadata": {},
  "created_at": "2026-09-06T10:24:00Z"
}
```


Keep `id`. Everything after this addresses it. `namespace` is a path you choose;
it groups chats for listing and selects which instructions, tools and policies
apply. It is immutable after creation. Omit it for the organisation-wide scope.

This call happens once per conversation. Steps 2 to 4 below are the loop, and
they repeat for its whole life: submit what just happened, read the stream, and
where a tool was called, submit its result and read the stream again. Steps 2
and 4 are the same endpoint.

## 2. Send what just happened

Send the prompt and the model. Never the transcript: Twigg has it.


```bash
curl -N -X POST 'https://api.twigg.ai/api/v1/chats/01912d4e-9c1a-7b3f-8a21-6f1e2c3d4b5a/responses' \
  -H "Authorization: Bearer $TWIGG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "model": "claude-opus-5",
  "input": [
    {
      "type": "prompt",
      "text": "What changed in the billing module?"
    }
  ],
  "idempotency_key": "billing-turn-1"
}'
```


The response is `text/event-stream`. There is no non-streaming mode. Pick any
`model` from `GET /api/v1/models`; it is a per-request choice, not a property of
the chat.

## 3. Read the stream

Events arrive in this order. Each is `event: <name>` followed by `data: <json>`.

| Event | Payload | Meaning |
|---|---|---|
| `run` | chat_id, closed_tool_calls, run_id | Always first: identifiers and tool calls closed by this submission. |
| `config_warnings` | warnings | Configuration warnings; emitted second, and may repeat after compaction. |
| `translation_warnings` | warnings | Model compatibility warnings before content starts. |
| `compacting` | blocks, in_progress_elsewhere | Compaction work the request is waiting for. |
| `block_start` | [BlockStart](#blockstart) |  |
| `delta` | [Delta](#delta) |  |
| `block_stop` | object |  |
| `done` | compaction, cost, cost_currency, model_served, pending_tool_calls, provider_response_id, server_tools, stop_reason, usage | Terminal success. Null cost means settlement is pending; inspect the run later. |
| `error` | code, details, hint, message | Terminal failure after the stream has opened. |

If `done.pending_tool_calls` is non-empty, the chat is waiting for you to run
those tools and send the results back.

Dropping the connection cancels nothing. The turn completes, appends to the
ledger and is billed. Read the answer from `/history` on reconnect.

## 4. Run the tool, send the result back

Same endpoint. A `tool_result` part instead of a `prompt`.


```json
{
  "model": "claude-opus-5",
  "input": [
    {
      "type": "tool_result",
      "tool_use_id": "toolu_example",
      "tool_name": "read_file",
      "text": "The billing module now records refunds.",
      "is_error": false,
      "trust": "customer_data"
    }
  ]
}
```


`trust` is `trusted`, `customer_data` (default) or `untrusted`. It sets the
write-time hygiene limits on the result. It is not an injection defence.

Read the stream exactly as in step 3; that is where the loop closes and begins
again.

Twigg never executes a tool. Every open tool call is closed on your next
submission: answered ones with your result, unanswered ones with a tombstone the
`run` event names in `closed_tool_calls`.

## Errors

Before the first byte, an ordinary HTTP status with one envelope:

```json
{ "error": { "code": "payment_required", "message": "…" } }
```

| status | code | when |
|---|---|---|
| 400 | `bad_request` | Malformed request, including an invalid UUID in a path. |
| 401 | `unauthorized` | Missing or invalid key. |
| 402 | `payment_required` | The balance cannot cover the turn's input. Top up in the console. |
| 404 | `not_found` | Unknown route, chat, run or model. Includes `hint: "https://api.twigg.ai/"` for discovery. |
| 409 | `conflict` | A run is already open on this chat, or the idempotency key names an earlier run. |
| 413 | `payload_too_large` | The body is over the size limit. |
| 422 | `validation_error` | A field failed validation, including tools the model cannot take. |
| 429 | `rate_limited` | Slow down. |
| 500 | `internal_error` | A fault on our side. |
| 502 | `external_service_error` | The upstream provider failed. |
| 503 | `unavailable` | Temporarily out of service. Retry. |

The `done.model_served` field is the provider-reported identifier, which can differ from the request's catalogue name: `gpt-5-6-luna` may be served as `gpt-5.6-luna`. Always select request models from the live catalogue's `name` field; do not copy `model_served` into a future request.

### Retrying after a dropped connection

Provider rate limits and overloads are retried up to three times before any model stream event is forwarded, with exponential backoff and jitter. `Retry-After` is respected; requests requiring more than 30 seconds of cumulative backoff fail without retrying early.

Model-provider failures include a safe category in `error.details.kind` and a `provider_request_id` when available. Failed runs retain these diagnostics in `manifest.error`, with the provider ID also available on the run; raw provider error bodies are not returned.

An `idempotency_key` prevents creating another run for the same submission; it does not replay a stream. Reusing a key within its 24-hour window returns `409 conflict`, with the original run ID in the error message. If you received the initial `run` event, you already have that ID.

Use `GET /api/v1/runs/{run_id}` to inspect the original run, and `GET /api/v1/chats/{chat_id}/history` to retrieve persisted output. If it is still running, check again later. Reuse the same key when the outcome of submission is uncertain; changing the key can create a second run and another charge.

### Retrying a confirmed failed run

A failed run retains submitted input without appending partial assistant output; retry the latest failed run with `POST /api/v1/chats/{chat_id}/responses`, setting `input: []`, `retry_of` to its run ID, and a new `idempotency_key`. Supply the model and any request-level tools again (these can change); the retry creates a separately billed run using the existing history and current configuration. If it fails, use the new run ID for the next retry, and discard partial streamed tool arguments after an `error` event.

An API key never earns a 403: a key that may not reach something is refused as
a 401. The console's own routes add `forbidden` and `forbidden_requires_admin`
for permission failures, which is a session's concern, not a key's.

A `validation_error` may carry a `details` object beside `code` and `message`.
Its `reason` can be `fixed_content_exceeds_budget` (with `fixed_tokens`,
`budget_tokens`), `current_turn_exceeds_capacity` (with `current_turn_tokens`,
`capacity_tokens`), or `checkpoint_exceeds_capacity` (with `required_tokens`,
`capacity_tokens`). These describe protected content that cannot fit in the
available input budget.

After the first byte, the same `code` and `message` arrive as the terminal
`error` event, since the HTTP status has already been sent.

## Reading back

```
GET /api/v1/chats/{chat_id}/history?after_ordinal=40&include=stubs
GET /api/v1/runs/{run_id}
GET /api/v1/chats?namespace=acme/proj-7&limit=20
GET /api/v1/namespaces
```

History is oldest first within a page and paged by `ordinal`, the ledger's
total order. A call with no cursor opens at the newest end: it returns the
last `limit` parts, and you page back with `before_ordinal`. Ordinals are
never renumbered and deletes leave gaps: page with the
`first_ordinal` and `last_ordinal` you were given, never by arithmetic.
`include` is `stubs` (default: attachment descriptors), `full` (base64 bytes,
page capped at 20 parts) or `none`.

## What you must not assume

- No transcript is ever sent by you. Sending prior messages as new prompts
  duplicates them in the ledger.
- A namespace is a scope, not a permission. Tenancy is your organisation behind
  the key; separating your own customers is your application's job.
- Uppercase in a namespace is a 422, not folded. Use lowercase opaque ids for
  per-user leaves.
- `model` is the catalogue name from `/api/v1/models`, never the provider's own
  wire string.

## Further reading

- Overview and concepts: https://twigg.ai/docs/overview.md
- Context control (budgets, retention, compaction): https://twigg.ai/docs/context.md
- Full API reference: https://twigg.ai/docs/api.md
- Model catalogue with prices: https://api.twigg.ai/v1/catalogue/models.md

## Sending attachments

Use the selected model's `media.user` or `media.tool_result` capability from
`GET /api/v1/models` to check formats and limits. Send attachments inline:

```json
{"type":"prompt","text":"Describe this image","media":[{"mime":"image/png","data_base64":"<standard base64 bytes>","filename":"image.png"}]}
```

Place this part in the response request's `input` array. Do not prefix the base64
with a data URI. Unsupported new media on configured models returns HTTP 422
without appending the turn. When switching models, retained historical media may
instead be omitted with translation warnings; see [context](context.md).
