# TwoTail read API

Read one session (a conversation: every turn sharing a `gen_ai.conversation.id`)
out of TwoTail, as the chat the user had: each turn's user message, the
agent's reply, its tool calls, what the user was shown, milestones, errors and
eval verdicts. Written for coding agents debugging the agent that sent the
traces.

- HTML version: https://www.twotail.ai/docs/read-api
- Sending traces instead: https://www.twotail.ai/docs/reference.md
- Last updated: 2026-11-04

## Key

Create a **Read** key in the TwoTail app under API keys. Read keys start with
`tt_read_`. They can only read, and only their own account. An ingest key
(`tt_live_`) is refused here, and a read key is refused for sending traces.

Keep the key in an environment variable such as `TWOTAIL_READ_KEY`. Never commit
it.

## Endpoint

```
GET https://www.twotail.ai/api/v1/session
X-API-Key: <TWOTAIL_READ_KEY>
```

Look up a session in one of two ways:

| Parameter | Meaning |
|---|---|
| `conversation_id` | The session's id: the `gen_ai.conversation.id` your spans carry. |
| `attribute` + `value` | Any attribute on a turn's root span, such as `request_id` (one turn) or `user.id` (many sessions). Returns the most recent matching session. |
| `days` | With `attribute`: how far back to search, 1 to 90 days. Default 30. |
| `offset` | Page back through a long session (see Paging). |

Give either `conversation_id` or `attribute` and `value`, not both.

```bash
curl -s -H "X-API-Key: $TWOTAIL_READ_KEY" \
  "https://www.twotail.ai/api/v1/session?conversation_id=conv_7d1e4b"

curl -s -H "X-API-Key: $TWOTAIL_READ_KEY" \
  "https://www.twotail.ai/api/v1/session?attribute=request_id&value=req_8f2a91"
```

URL-encode the values (`curl -G --data-urlencode "value=..."`).

## Response

```json
{
  "conversation_id": "conv_7d1e4b",
  "started_at": "2026-10-26T09:12:40+00:00",
  "test": {"run_id": "2026-11-03-nightly-a1b2c3", "case_id": "books-cheaper-option",
           "level": null, "agent_version": "a1b2c3d"},
  "session_evals": [
    {"name": "user_frustration", "passed": false, "reason": "...", "turn": 4, "evidence": true}
  ],
  "turns": [
    {
      "index": 3,
      "trace_id": "0af7651916cd43dd8448eb211c80319c",
      "start": "2026-10-26T09:15:02+00:00",
      "user": "Book the cheaper one",
      "reply": "Done: the 09:10 option is in your basket...",
      "steps": [{"label": "search_options", "args": "{...}", "result": "{...}"}],
      "shown": [{"noun": "options", "cards": [{"title": "Option B", "price": "84.00 EUR"}], "chosen": null}],
      "facts": {"plan": "pro"},
      "markers": [{"name": "acme.option_chosen", "label": "Option chosen"}],
      "errors": [{"type": "AI_APICallError", "message": "..."}],
      "evals": [{"name": "price_checked", "passed": false, "reason": "..."}],
      "cost": 0.0123, "tokens": 18234, "duration_s": 6.2, "error": false
    }
  ],
  "total_turns": 7,
  "returned_turns": 7,
  "first_turn_index": 0,
  "offset": 0,
  "next_offset": null
}
```

An attribute lookup also returns:

- `matched_trace_ids`: the turns of this session that carry the attribute;
- `other_matches`: older sessions that also match (conversation ids, up to 10).

Notes on the fields:

- `test` is the session's test run attributes (`test.run_id`, `test.case_id`,
  `test.level`, `agent.version`), so a session can be checked against its
  case. It is `null` for live traffic.
- `turns[].trace_id` is the turn's trace. Every span of the turn shares it.
- `steps[].args` and `steps[].result` are JSON text, cut at 4,000 characters.
- `session_evals[].turn` is the `index` of the turn the verdict points at.
- `markers` are named span events your agent sent (milestones), and `errors`
  are `exception` events.
- A field may be `null` or empty when your spans don't carry it.

## Paging

A long session is cut to about 100 KB of turns (around 25k tokens), newest
first, so one response fits in an agent's context. A turn larger than that
still comes back on its own.

- `total_turns` is the whole session; `first_turn_index` is the oldest turn
  returned.
- When `next_offset` is not null, call again with `offset=<next_offset>` for
  the turns before.
- An attribute lookup's page ends at the matched turn, not at the newest one.

## Errors

| Status | When |
|---|---|
| `400` | Neither or both of `conversation_id` and `attribute`, or `attribute` without `value`. |
| `401` | Missing, revoked or expired key, or a key that isn't a read key. |
| `404` | No session with that id, or none matching the attribute within `days`. |
| `504` | The attribute lookup took too long. Retry with a smaller `days`. |

## Stability

The fields above are a contract under `/v1`: fields may be added, and none
are renamed or removed without a new version.
