---
title: "Read API: sessions and calls · coding.sh docs"
description: "The /api routes the viewer and your scripts read, their auth and errors: projects, sessions, events, outlines, replays, blobs and model calls."
canonical: https://coding.sh/docs/api/read
human_url: https://coding.sh/docs/api/read
markdown_url: https://coding.sh/docs/api/read.md
agents_url: https://coding.sh/agents.md
llms_url: https://coding.sh/llms.txt
updated: 2026-11-11
---

# Read API: sessions and calls

The /api routes the viewer and your scripts read, their auth and errors: projects, sessions, events, outlines, replays, blobs and model calls.

## Read API

All `/api/*` routes take a read key or basic auth, answer JSON with `Cache-Control: no-store`, and share these errors:

| Status | When | Body |
| --- | --- | --- |
| 400 | a bad parameter | `{"error":"limit must be between 1 and 500","status":400}` |
| 401 | no valid read credential | `{"error":"missing or invalid token","status":401}` |
| 403 | the server binds loopback and the `Host` header is not loopback (DNS-rebinding guard) | `{"error":"forbidden host","status":403}` |
| 404 | unknown route, a session outside the key's project, or a session id with a raw `/` (the error says to send `%2F`) | `{"error":"not found","status":404}` |
| 405 | wrong method | `{"error":"method not allowed","status":405}` |

Times are epoch milliseconds. `since` is inclusive and `until` exclusive, both on a session's start.

The examples use `$CSH` (the server's base URL) and `$READ_KEY` from [Basics](https://coding.sh/docs/api#basics).

### `GET /api/projects`

Project ids the server knows: `default`, every project in the config, every project named by a configured key, and every project that has sessions in the store (so a `--role viewer` replica with only a `*` key still lists them all). A scoped read key gets only its own project.

shell

```
curl -sS "$CSH/api/projects" -H "Authorization: Bearer $READ_KEY"
```

json

```
{"items":["default","support","team"]}
```

### `GET /api/sessions`

Sessions, newest first.

| Parameter | Default | Meaning |
| --- | --- | --- |
| `limit` | 50 | 1 to 500 |
| `offset` | 0 | rows to skip |
| `cursor` |  | `nextCursor` from the previous page; steadier than `offset` while data arrives |
| `project` | all | one project (`*` read keys only; scoped keys are always filtered) |
| `scope` | `all` | `top` leaves out subagent sessions |
| `parent` |  | only children of this session id |
| `agent`, `model`, `flag`, `repo` |  | exact match on the session |
| `since`, `until` |  | start time range |
| `q` |  | substring of title, id, repo, branch or model (up to 500 characters) |
| `where` |  | a [`--where` query](https://github.com/jackulau/coding.sh/blob/main/docs/reference/query.md#filter-with---where), and-ed with the other filters |

shell

```
curl -sS "$CSH/api/sessions?project=team&limit=1" -H "Authorization: Bearer $READ_KEY"
```

json

```
{
  "items": [{
    "id": "team/triage-12", "agent": "triage-agent", "startedAt": 1793880000000, "endedAt": 1793880001200,
    "stats": {"events": 3, "turns": 1, "toolCalls": 0, "toolErrors": 0, "filesChanged": 0,
      "usage": {"inputTokens": 410, "outputTokens": 38, "costUsd": 0.001405},
      "latency": {"wallMs": 1200, "llmCalls": 1, "llmMs": 1200, "llmP50Ms": 1200, "llmP95Ms": 1200}},
    "flags": [], "model": "gpt-4o", "traceIds": ["4bf92f3577b34da6a3ce929d0e0e4736"], "project": "team", "childCount": 0
  }],
  "limit": 1, "offset": 0, "hasMore": true, "nextCursor": "1793880000000:team/triage-12"
}
```

`childCount` is the number of subagent sessions under each row. `nextCursor` is present only when `hasMore` is true; pass it back as `cursor` for the next page:

shell

```
curl -sS "$CSH/api/sessions?project=team&limit=1&cursor=1793880000000:team/triage-12" -H "Authorization: Bearer $READ_KEY"
```

That page holds `team/chat-1`.

With `where`, rows follow the query's `sort:` (newest first without one) and pages go by `offset`; `cursor` answers 400. A bad query answers 400 with `reason`, the 0-based `offset` of the problem and `next`:

shell

```
curl -sS "$CSH/api/sessions?where=agent:codex%20modle:x" -H "Authorization: Bearer $READ_KEY"
```

json

```
{"error":"where: unknown key \"modle\"","status":400,"reason":"unknown_key","offset":12,"next":["did you mean model:?"]}
```

### `GET /api/sessions/{id}`

One session, the same shape as a list row without `childCount`. Percent-encode the id.

shell

```
curl -sS "$CSH/api/sessions/team%2Fchat-1" -H "Authorization: Bearer $READ_KEY"
```

json

```
{"id":"team/chat-1","agent":"support-bot","startedAt":1793872800000,"stats":{"events":5,"turns":1,"toolCalls":1,"toolErrors":0,"filesChanged":0,"usage":{"inputTokens":820,"outputTokens":46,"costUsd":0.00251,"estimatedCostUsd":0.00251},"latency":{"wallMs":3000,"llmCalls":1,"llmMs":900,"llmP50Ms":900,"llmP95Ms":900,"toolMs":400,"toolP95Ms":400}},"flags":[],"model":"gpt-4o","title":"Where is order 1042?","endedAt":1793872803000,"project":"team"}
```

The fields below are grouped by topic: cost, parent and model, model switches, proxy calls, API errors, waste and cache breaks, launch and forks, hooks and redaction, memory and instruction files, unapproved actions, and tool errors and fold errors.

Cost.

| Field | Meaning |
| --- | --- |
| `stats.usage.costUsd` | The reported or gateway-billed cost where there is one, else token counts times list price |
| `stats.usage.estimatedCostUsd` | The part of `costUsd` that was estimated (in the example, all of it) |
| `stats.usage.estimatedCostByTier` | The estimate totalled by `priceTier`. Left out when every call was standard |
| `stats.duplicateUsage` (`calls`, `tokens`, `costUsd`) | Model-call usage left out because an enclosing call or an earlier copy of the same response already counted it |

Each estimated `llm.call` has `payload.costEstimated: true`. It adds `payload.pricedAs` when a sibling model's price was used, and `payload.priceTier` when it was priced at batch, flex, priority, fast, long-context or US-only rates (`batch+long-context` for both).

Parent and model.

| Field | Meaning |
| --- | --- |
| `parent` | Set on a child session: `sessionId`, plus `eventId`, `subagentId` and `type` when known |
| `parent.type` | The subagent type (`explore`, …), or `"harness"` for an agent started by another agent's shell tool (`codex exec` from Claude Code's Bash tool) |
| `model` | The model that made most of the main-thread model calls (calls without a `subagentId`; the earlier one on a tie), else the last model any event named |
| `stats.models` | Model calls per model, subagent calls included (`{"claude-sonnet-5-5": 3, "claude-sonnet-5": 2}`). Absent when one model or none made them |
| `stats.modelSwitch` | The session's first model switch, described below |

A harness link named by the child's hook (`spawnedBy`) has no `eventId`. It is set only when that session is in the store and was open when the child started, and the child's `session.start` payload does not carry it.

Model switches. `stats.modelSwitch` (`from`, `to`, `at`, `trigger`) is the last call on `from`, then the first call on `to` at `at`, in the same scope (the main thread, or one subagent). A change is a switch when:

- the agent recorded a fallback for it,
- it is inside a subagent (a Claude Code subagent's own session included), or
- the main thread handed over: every main-thread call on `from` came before the first call on `to`.

Without a trigger or a record, each main-thread model needs at least two calls. So a helper model's single call before or after the others (a title) is not a switch, and a helper model whose calls run between the main model's never is. A switch and back is one switch.

| Field | Meaning |
| --- | --- |
| `trigger` | `fallback` for a recorded fallback (with `reason`, such as `overloaded` or `refusal`); `refusal` or `error` when the last call on `from` stopped on a refusal or failed; `command` when a `/model` command came right before the first call on `to`; absent otherwise |
| `subagent` | `true` when the first switch was in a subagent |
| `count` | The number of switches, when there was more than one |
| `switchedCalls` | Calls that ran on a model a switch moved to, until the scope went back |
| `extraUsd` | What those calls cost at their model's list prices, minus the same tokens at `from`'s list prices (standard rates, so negative when the other model is cheaper). Absent when either model has no price |

- A switch adds the `model:switch` tag to `flags`.
- Claude Code's `model_fallback` and `model_refusal_fallback` transcript records become a `notification` with `payload.kind: "model.fallback"` and add the `model:fallback` tag.
- A call whose `payload.stopReason` is `refusal`, `content_filter` or `safety` (any case) adds the `refusal` flag.
- Calls logged by `coding.sh proxy` count here only in a session with no other model calls, as they do for usage.

Proxy calls. Model calls logged by [`coding.sh proxy`](https://coding.sh/docs/how-to/capture-with-proxy) have `source: "proxy"`. Their payload adds these fields. `reasoningEffort` through `compaction` appear when the request or response has them, and `parentSessionId` through `upstreamAuth` when the client names them.

| Field | Meaning |
| --- | --- |
| `provider`, `api` | `api` is `messages`, `count_tokens`, `chat` or `responses` |
| `statusCode`, `stream`, `ttfbMs`, `ttftMs`, `stopReason` | The answer's status, whether it streamed, time to first byte and to first token, and the stop reason |
| `systemHash`, `toolsHash` | 16 hex characters each |
| `toolNames`, `toolCount`, `messageCount` | The request's tools and messages |
| `prompt` | The last user message, clipped |
| `linkedBy` | How the call found its session: `header`, `metadata`, `previous-response` or `conversation` |
| `rlRequestsRemaining`, `rlTokensRemaining`, … | Rate limits, when the API sent them |
| `errorClass` | On failure; values below |
| `requestRef`, `responseRef` | The stored request and response blobs. Absent in `metadata` mode |
| `requestPaged` | `true` when the request's messages are stored in blocks, not inside the request blob (`show <session>#<n>` puts them back) |
| `reasoningEffort` | From `reasoning.effort`, `reasoning_effort` or `output_config.effort` |
| `serviceTier`, `speed` | The response's value, else the request's. `requestedServiceTier` when the request asked for another tier |
| `thinkingBudget` | The request's thinking budget |
| `compaction` | `true` on a call that compacted the conversation (Codex's `/responses/compact`, or a `compaction` block or output item) |
| `parentSessionId` | Named by the client: the session that started this one. For a Claude Code subagent, whose calls go to `<session>:sub:<agentId>`, the parent's |
| `agentId`, `parentAgentId`, `agentType` | Named by Claude Code |
| `forkedFromSessionId`, `subagentKind` | Named by Codex |
| `accountHash` | 16 hex characters of the sha256 of the account id |
| `upstreamAuth` | CLIProxyAPI's credential index |
| `gateway` | The gateway's id (for example `cliproxyapi`), when the upstream the call went on to is a known gateway. It tags the session `gateway:<id>` |

A session seen only through the proxy takes its `parent` from its calls' `parentSessionId`. When the session also has model calls from the agent's transcript or OpenTelemetry, those count toward `stats` and the proxy's copies are detail only. Failed proxy calls count either way.

API errors.

| Field | Meaning |
| --- | --- |
| `stats.llmErrors` (`count`, `statusCode`, `error`) | `llm.call` events with `ok: false` or a status of 400 or more, with the first failure's status and error. Absent when none failed |

`errorClass` is one of `rate_limit` (429), `overloaded` (529), `auth` (401, 403), `too_large` (413), `timeout`, `server` (other 5xx), `invalid_request` (other 4xx), `network` and `api`.

Each API error Claude Code retried (a transcript's `api_error` line) is an `error` event with `source: "api"`. A backfill also keeps what the transcript records about it:

| Field | Meaning |
| --- | --- |
| `statusCode`, `errorType` | The HTTP status and the API's error type (`rate_limit_error`, `overloaded_error`, …) |
| `errorClass` | As above |
| `rateLimitType` | The limit that was hit, such as `seven_day` or `five_hour` |
| `resetsAt` | When the limit resets (ms) |
| `retryAfterMs` | How long Claude Code waited before the next attempt |
| `retryAttempt`, `maxRetries` | Which attempt this was, out of how many |

A weekly-limit stall then reads as hundreds of `rate_limit` errors on `seven_day`, not as one transient error.

Waste and cache breaks. Both stats are absent when there is nothing to report.

| Field | Meaning |
| --- | --- |
| `stats.waste` (`duplicateFetches`, `duplicateFetchTokens`, `readOnlyCalls`) | Read-only calls that repeated an earlier call in the same context with the same result, and the tokens they sent again (output length / 4) |
| `stats.cacheBreaks` (`count`, `lostTokens`, `extraUsd`, `byCause`) | Model calls that found the prompt cache gone: the previous prompt was over 4,096 tokens, this call read less than half of it from cache, and more than 2,048 tokens were billed again |
| `stats.cacheBreaks.byCause` | `compaction`, `model-switch`, `idle` (5 minutes, or an hour after a 1-hour cache write) and `other` |
| `stats.cacheBreaks.byIdle` | Idle breaks by where the wait was: `wait` (one tool call in the same scope ran past the cache lifetime, such as a Bash command polling a job), `subagent` (a subagent sat idle between turns) or `away` (the main session did). Absent when no break was idle |
| `stats.cacheBreaks.extraUsd` | The cache-write (or input) price minus the cache-read price for those tokens. Left out when the model has no price; `unpriced` then counts the breaks |

- `--where cache.breaks>0`, `cache.lost` (the re-billed tokens) and `cache.cost` (`extraUsd`) query and sort on them, e.g. `coding.sh sessions --where 'cache.breaks>0 sort:-cache.cost'`.
- A session that wrote a prompt cache of at least one block over at least three turns, but read back fewer tokens than it wrote, is tagged `cache:low`.
- [`coding.sh usage --cache`](https://coding.sh/docs/cli/usage) reports the hit rate, breaks and their cost per group. `exp compare` adds the `cache breaks / run` and `cache waste / run` metrics.

Launch and forks.

| Field | Meaning |
| --- | --- |
| `stats.launch` | `headless` for a session run without a person at the keyboard (`claude -p`, the Claude Agent SDK, `codex exec`, Codex as an MCP server), `interactive` for a terminal or IDE session. From Claude Code's `entrypoint` and Codex's `originator` and `launch` ([details](https://github.com/jackulau/coding.sh/blob/main/docs/ADAPTERS.md)). Absent when the start does not say. Used by `--where launch:headless` and `--facets launch` |
| `stats.inheritedTokens` | On a Claude Code fork subagent (`subagent_type: "fork"`): the tokens of the parent's context it started with, the prompt and output of the parent's call that started it. Absent for other sessions |

- A fork subagent's session holds only its own model calls, so the parent's calls count once, in the parent. The fork's reads of the inherited context from the prompt cache count in its `usage`.

Hooks and redaction.

| Field | Meaning |
| --- | --- |
| `stats.hookContext` (`count`, `tokens`, `bySource`) | Texts hooks added to the model's context (Claude Code), and their size in tokens (characters / 4). Absent when no hook added context |
| `stats.hookContext.bySource` | Whose hook it was: `plugin:<name>`, `plugin` (a plugin not installed where the session was parsed), `settings` (a hook in a settings file) or `unknown` (the agent recorded no command). Each source adds a `hook:<source>` tag, such as `hook:plugin:terse` |
| `stats.redactions` | Values redaction replaced before storage, by rule kind (`{"anthropic-key": 1, "password": 2}`). Never holds the values. Absent when nothing was redacted |

- Redaction counts per event are in `payload.redactions`, which metadata mode keeps. A tool output moved to a blob counts what was redacted inside the blob.
- A vendor token kind adds the `secret:exposed` tag to `flags`.
- A vendor-shaped value that is plainly made up counts as `placeholder-key`, which adds no tag: a published example key, EXAMPLE, test or stub after the prefix, a repeated character or chunk, or eight consecutive characters.
- Events stored before redaction counting existed are counted from the `[redacted:<kind>]` markers in their payload.

Memory and instruction files. `stats.memoryUse` lists the memory and instruction files the session's events name (AGENTS.md and the other agents' instruction files, Claude auto-memory, rules, skills). It is what `coding.sh harness audit` counts.

| Field | Meaning |
| --- | --- |
| `use` | `loaded`, `read` or `wrote` |
| `path`, `ts` | The file's absolute path, and the last time of that use |
| `skill`, `by` | Instead of `path`, for a skill an event names without a path: its name, and `tool` (a Skill tool call) or `harness` (the agent's own record of loaded skills) |

- Files the agent loads at start without an event are not in it.
- It is absent when the events name none, and metadata sinks do not get it. See [Memory and instruction files](https://github.com/jackulau/coding.sh/blob/main/docs/guides/explore.md#memory-and-instruction-files).

Unapproved actions.

| Field | Meaning |
| --- | --- |
| `stats.unapprovedActions` (`count`, `byKind`, `first`) | Destructive or paid commands the agent ran after a user turn that did not approve them (the `unapproved-action` flag; rules in [concepts](https://coding.sh/docs/concepts)). Absent when there were none. `--where unapproved>0` lists those sessions |
| `stats.unapprovedActions.byKind` | `rm-rf`, `git-reset-hard`, `git-clean`, `git-discard`, `git-push-force`, `db-drop`, `forced-subcommand`, `data-dir` and `paid` |
| `stats.unapprovedActions.first` | `kind`, `command` (whitespace collapsed, at most 200 characters), `at` (ms), and `afterError: true` when a tool call had failed since the user's turn |

Tool errors and fold errors.

| Field | Meaning |
| --- | --- |
| `stats.toolErrors` | Tool calls that really failed |
| `stats.benignToolErrors` | Non-zero exits that are not errors: a shell search that found nothing (`grep`, `rg`, `git grep` and the like exiting 1 with no output), a probe that answered no (`test -f`, `which`, `command -v`), and a `diff`, `cmp` or `git diff --exit-code` that found differences. Absent when zero |
| `stats.interruptedToolCalls` | Calls the user or the agent stopped (exit 130, or an interrupted result). Absent when zero |
| `stats.foldError` | Set when coding.sh could not fold the session's stored events into its stats, a coding.sh bug, not the agent's. The text is the error, at most 500 characters. Absent when the fold worked |

- A session with `stats.foldError` holds only what the events say directly: the agent, the first and last event times, the cwd and `stats.events`, plus the `fold:error` tag. `coding.sh ingest` folds it again, and `coding.sh doctor` counts these sessions.
- The benign and interrupted rule also decides `repeated-tool-failure`, which only counts real failures, and each phase's `toolErrors` in the outline.

Three `notification` kinds carry the events behind these stats:

| `payload.kind` | Fields |
| --- | --- |
| `model.fallback` | `from`, `to`, `trigger`, `direction`, `text` |
| `fork` | `toolUseId` (the parent's Agent call) and `inheritedTokens` |
| `hook.context` | `hookEvent`, `hookName`, `hookSource`, `command`, `chars`, `tokens` and `text` (clipped). Metadata mode keeps only `hookEvent`, `hookSource`, `chars` and `tokens` |

A missing session, or one outside a scoped key's project, gets 404:

shell

```
curl -sS "$CSH/api/sessions/team%2Fchat-1" -H "Authorization: Bearer $SUPPORT_READ_KEY"
```

json

```
{"error":"session not found","status":404}
```

### `GET /api/sessions/{id}/events`

A session's events in order. Parameters: `limit` (1 to 20,000, default 5,000), `offset`, and `kinds` (comma-separated). Native-ingest ids come back as stored hashes.

shell

```
curl -sS "$CSH/api/sessions/team%2Fchat-1/events?kinds=tool.start,tool.end" -H "Authorization: Bearer $READ_KEY"
```

json

```
{
  "items": [
    {"id": "54b366d0…", "v": 0, "sessionId": "team/chat-1", "ts": 1793872802000, "agent": "support-bot",
     "kind": "tool.start", "payload": {"tool": "lookup_order", "toolUseId": "t1", "input": {"id": 1042}},
     "source": "sdk", "project": "team"},
    {"id": "22270b1c…", "v": 0, "sessionId": "team/chat-1", "ts": 1793872802400, "agent": "support-bot",
     "kind": "tool.end", "payload": {"tool": "lookup_order", "toolUseId": "t1", "ok": true, "output": "shipped"},
     "durationMs": 400, "source": "sdk", "project": "team"}
  ],
  "limit": 5000, "offset": 0, "hasMore": false
}
```

### `GET /api/sessions/{id}/outline`

A long session at a glance: its phases (the start, each resume, compaction and long idle gap), the first event of each local day, and its derails. `index`, `from` and `to` count events in time order, the same order `/events` pages through.

shell

```
curl -sS "$CSH/api/sessions/team%2Fchat-1/outline" -H "Authorization: Bearer $READ_KEY"
```

json

```
{
  "phases": [
    {"n": 1, "kind": "start", "label": "start", "startTs": 1793872802000, "endTs": 1793876402000,
     "from": 0, "to": 1840, "eventId": "54b366d0…", "events": 1840, "prompts": 6, "toolCalls": 512,
     "toolErrors": 9, "filesChanged": 14, "tokens": 2210000, "derails": []},
    {"n": 2, "kind": "compaction", "trigger": "auto", "label": "compacted (auto)", "startTs": 1793876410000,
     "endTs": 1793880010000, "from": 1840, "to": 3600, "eventId": "9a1f03c2…", "events": 1760, "prompts": 2,
     "toolCalls": 498, "toolErrors": 31, "filesChanged": 3, "tokens": 1980000,
     "derails": [{"flag": "failure-streak", "ts": 1793877000000, "eventId": "c41e88a0…", "index": 2210,
                  "endTs": 1793877090000, "detail": "6 failed tool calls in a row (Bash)"}]}
  ],
  "days": [{"date": "2026-11-04", "ts": 1793872802000, "index": 0, "eventId": "54b366d0…"}],
  "derails": [{"flag": "failure-streak", "ts": 1793877000000, "eventId": "c41e88a0…", "index": 2210,
               "endTs": 1793877090000, "detail": "6 failed tool calls in a row (Bash)"}],
  "events": 3600,
  "turns": {"count": 8, "lengths": [412, 260, 1168, 300, 512, 220, 410, 318], "failed": [2, 4]},
  "failures": {"count": 40, "at": [655, 1902, 1904]},
  "compactions": [1840],
  "subagents": {"count": 1, "items": [{"id": "a33e97aa…", "start": 2950, "end": 3011, "type": "general-purpose", "childSessionId": "…"}]},
  "minimap": {"size": 30, "rows": [[30, 0, 12, 0], [30, 2, 14, 0]]}
}
```

The span fields come from the session's span model. A turn starts at each prompt of the main agent (or a turn id not seen before); a subagent's events stay in the turn that ran it.

| Field | Meaning |
| --- | --- |
| `turns.lengths` | The number of events in each turn, so a turn's first index is the sum of the lengths before it. At most 2,000 turns, with `"truncated": true` past that |
| `failures.at` | The first 100 failed events: failed tool calls, errors, and model calls, subagents or test runs that failed |
| `minimap.rows` | At most 120 runs of `size` events, each `[events, failures, tool calls, compactions]` |

A session over 20,000 events builds its model in the background the first time: that outline has `"spans": "building"` in place of the span fields. Sessions of 5,000 events or more keep their model in the index, so a restarted viewer does not build it again.

### `GET /api/sessions/{id}/rows`

One page of a turn's rows, for a timeline that shows a long session without loading it.

| Parameter | Default | Meaning |
| --- | --- | --- |
| `turn` | 0 | The turn, from 0 |
| `after` |  | The last event index already shown; the page starts after it |
| `limit` | 200 | 1 to 1,000 rows |
| `fold` |  | `0` shows every event |

A run of 12 or more routine events (model calls, successful tool calls, spans, notifications) folds into one row, with its first and last three events left as rows. A fold never holds a failure, a prompt, a reply, a compaction, a subagent's start or end, a file change or a test result.

Open a fold by asking for its range with `after=<from - 1>&fold=0`. `next`, when present, is the `after` of the next page. A `turn`, `after` or `limit` out of range is a 400.

shell

```
curl -sS "$CSH/api/sessions/team%2Fchat-1/rows?turn=0&limit=3" -H "Authorization: Bearer $READ_KEY"
```

json

```
{
  "turn": {"n": 0, "start": 0, "end": 4, "ts": 1793872800000, "endTs": 1793872803000, "tools": 1, "calls": 1,
           "failures": 0, "subagents": 0, "compactions": 0, "prompt": "Where is order 1042?"},
  "rows": [
    {"i": 0, "id": "d4c45c89…", "kind": "user.message", "ts": 1793872800000, "label": "Where is order 1042?"},
    {"i": 1, "id": "0889ecc3…", "kind": "llm.call", "ts": 1793872801000, "label": "gpt-4o", "durationMs": 900},
    {"i": 2, "id": "54b366d0…", "kind": "tool.start", "ts": 1793872802000, "label": "lookup_order"}
  ],
  "next": 2
}
```

`chat-1` is one turn of five events, so nothing in it folds; `turn` also has a `turnId` when the events carry one. A long session answers the same way, with a `fold` row for each folded run and `next` while the turn has more rows. A fold row, as a fragment that is not from `chat-1`:

json

```
{"fold": {"from": 674, "to": 701, "events": 28, "tools": 12, "calls": 4}}
```

### `GET /api/sessions/{id}/tree`

The whole multi-agent run this session belongs to, as a tree: its top-level agent (found by following `parent` up) and every subagent under it.

- Each node carries the session's own model, `usage`, tool calls, files changed and failure flags.
- `totals` sums them over the whole tree, with the run's wall `durationMs` and every failure flag in it, worst first.
- `worst` names the node to fix first (the worst-flagged, costliest session), or is null when nothing is flagged.

shell

```
curl -sS "$CSH/api/sessions/9f1c2e…/tree" -H "Authorization: Bearer $READ_KEY"
```

json

```
{
  "root": "9f1c2e…",
  "totals": {
    "sessions": 4, "usage": {"inputTokens": 2100000, "outputTokens": 41000, "costUsd": 3.74},
    "toolCalls": 612, "toolErrors": 40, "filesChanged": 17, "durationMs": 1800000,
    "flags": ["premature-completion"]
  },
  "worst": {"sessionId": "9f1c2e…:sub:a2", "role": "fixer", "flags": ["premature-completion"]},
  "tree": [
    {"id": "9f1c2e…", "agent": "claude-code", "role": "root", "model": "claude-sonnet-5",
     "depth": 0, "durationMs": 1800000, "usage": {"costUsd": 1.1}, "toolCalls": 300, "toolErrors": 2,
     "filesChanged": 3, "flags": [], "children": [
       {"id": "9f1c2e…:sub:a2", "role": "fixer", "depth": 1, "usage": {"costUsd": 2.0},
        "toolCalls": 180, "toolErrors": 31, "flags": ["premature-completion"], "children": []}
     ]}
  ]
}
```

### `GET /api/sessions/{id}/replay`

The session as numbered steps, and the files the agent touched as of any step, rebuilt from its own tool calls. A step is a prompt, a reply, a tool call (with the file changes and test results it produced), a subagent start, a compaction, an error, or a file change no tool call claims.

Without parameters it returns the step list. `steps[].eventId` is the step's first event, and `files` names the paths each step wrote, deleted or fully read (relative to `root` when inside it). `baseline` is `snapshot` when `coding.sh ui` can read the session's start snapshot, else `none`.

shell

```
curl -sS "$CSH/api/sessions/cd0185b0-305a-4df2-92fa-6fc6caffdb55/replay" -H "Authorization: Bearer $READ_KEY"
```

json

```
{
  "sessionId": "cd0185b0-305a-4df2-92fa-6fc6caffdb55", "root": "/work/demo", "baseline": "none",
  "steps": [
    {"n": 1, "ts": 1794084489400, "kind": "prompt", "label": "Tidy notes.txt and greet.", "eventId": "0c1f…"},
    {"n": 2, "ts": 1794084489611, "kind": "read", "label": "Read notes.txt", "eventId": "5e2a…", "tool": "Read",
     "ok": true, "durationMs": 38, "files": [{"path": "notes.txt", "op": "read"}]},
    {"n": 4, "ts": 1794084489801, "kind": "shell", "label": "$ sed -i 's/alpha/beta/' notes.txt", "eventId": "9b7d…",
     "tool": "Bash", "ok": true, "durationMs": 171, "files": [{"path": "notes.txt", "op": "modify"}]}
  ],
  "files": ["gen.txt", "notes.txt", "out.txt", "sub/inner.txt"]
}
```

| Query | Returns |
| --- | --- |
| `step=N` | What step N did: `detail` (the prompt or reply text, the tool input, or for a shell call `shell` with `command`, `exitCode` when known, `durationMs` and `output`), `changes` (one entry per file with a unified `diff`), and `tree` (every file touched up to step N, with `status` `added`, `modified`, `deleted` or `read`) |
| `step=N&file=<path>` | The same, plus that file's content as of step N |
| `from=A&to=B` | What steps A through B changed together, one diff per file |

A diff with `exact: true` compares the rebuilt file before and after, with real line numbers.

When the content on one side is unknown, `exact` is `false`, the diff is the change as the agent recorded it, and `why` says what is missing, for example `Step 3 patched it before any step showed its content (no earlier read, full write or start snapshot).`

A file's content is unknown until a step writes or fully reads it, or the start snapshot has it. An edit that does not match the rebuilt content makes it unknown again.

shell

```
curl -sS "$CSH/api/sessions/cd0185b0-305a-4df2-92fa-6fc6caffdb55/replay?step=4&file=notes.txt" -H "Authorization: Bearer $READ_KEY"
```

json

```
{
  "sessionId": "cd0185b0-305a-4df2-92fa-6fc6caffdb55", "steps": 15,
  "step": {"n": 4, "kind": "shell", "label": "$ sed -i 's/alpha/beta/' notes.txt", "tool": "Bash", "ok": true, "durationMs": 171},
  "detail": {"input": {"command": "sed -i 's/alpha/beta/' notes.txt"},
             "shell": {"command": "sed -i 's/alpha/beta/' notes.txt", "ok": true, "durationMs": 171, "output": "…"}},
  "changes": [{"path": "notes.txt", "op": "modify", "exact": true,
               "diff": "--- a/notes.txt\n+++ b/notes.txt\n@@ -1,2 +1,2 @@\n-alpha\n+beta\n bravo\n"}],
  "tree": [{"path": "notes.txt", "status": "modified", "edits": 1, "lastStep": 4, "here": true}],
  "file": {"path": "notes.txt", "exists": true, "content": "beta\nbravo\n", "changedAt": 4}
}
```

A step past the last one gets 400. File content is cut at 1 MiB (`contentTruncated`) and a diff at 512 KB (`diffTruncated`).

### `GET /api/sessions/{id}/fork`, `POST /api/sessions/{id}/fork`

Fork the session at a replay step: send the model call behind that step again with changed context files (AGENTS.md and the like) and compare the forked next action with the recorded one. Only `coding.sh ui` answers these routes; `coding.sh serve` answers 404. The session needs model calls captured by [`coding.sh proxy`](https://coding.sh/docs/how-to/capture-with-proxy). When forking is on, the replay step list adds `"canFork": true`.

`GET ?step=N` returns the call a fork at step N starts from and the context files it carried. It does not call the model.

- `by` is `tool-use` when the call's reply holds the step's tool call, else `time` (the agent's last call before the step, or for a prompt the first call after it).
- `canCall` is false when neither `keyEnv` nor `CLAUDE_CODE_OAUTH_TOKEN` (for Anthropic) is set in the environment `coding.sh ui` runs in.

shell

```
curl -sS "http://127.0.0.1:4319/api/sessions/cd0185b0-305a-4df2-92fa-6fc6caffdb55/fork?step=4" -H "Authorization: Bearer $UI_TOKEN"
```

json

```
{
  "step": {"n": 4, "kind": "shell", "label": "$ npm test"},
  "call": {"id": "7a1c…", "model": "claude-sonnet-4-5", "by": "tool-use", "messages": 5},
  "files": [{"path": "AGENTS.md", "text": "Run the tests with npm test.\n"}],
  "recorded": {"action": {"type": "tool", "name": "Bash", "input": {"command": "npm test"}}, "label": "Bash: npm test"},
  "canCall": true, "keyEnv": "ANTHROPIC_API_KEY"
}
```

`POST` with `{"step": N, "files": {"AGENTS.md": "…"}, "trials": 1}` sends the call with each file replaced, added, or removed when its text is empty. `trials` is 1 to 5. The request must come from the viewer's own origin. It calls the model and costs money.

In the answer, `edits` says what changed in the request, each trial has the forked first action and `differs`, and `differ` counts the trials whose action is not the recorded one.

json

```
{
  "sessionId": "cd0185b0-305a-4df2-92fa-6fc6caffdb55",
  "step": {"n": 4, "kind": "shell", "label": "$ npm test"},
  "call": {"id": "7a1c…", "model": "claude-sonnet-4-5", "by": "tool-use", "messages": 5},
  "change": "AGENTS.md changed",
  "edits": [{"path": "AGENTS.md", "how": "replaced", "count": 1}],
  "recorded": {"action": {"type": "tool", "name": "Bash", "input": {"command": "npm test"}}, "label": "Bash: npm test"},
  "trials": [{"action": {"type": "tool", "name": "Bash", "input": {"command": "node --test"}}, "label": "Bash: node --test",
              "differs": true, "model": "claude-sonnet-4-5", "costUsd": 0.0123}],
  "differ": 1
}
```

A step out of range, a session without stored requests, or a missing key gets 400 with the reason.

### `GET /api/playground`, `GET /api/playground/calls/{id}`, `POST /api/playground/run`, `POST /api/playground/save`

Rebuild one recorded model call as an editable request, send it again, and save it as a dataset case. Only `coding.sh ui` answers these routes; `coding.sh serve` answers 404.

`GET /api/playground` lists the providers and the datasets a case can go to. A provider is `ready` when its key is set where `coding.sh ui` runs, or when its base URL is local: `anthropic` (Messages API; `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`), `openai` (Responses API) and `openai-compatible` (chat completions), both from `OPENAI_API_KEY` and `OPENAI_BASE_URL`.

`GET /api/playground/calls/{id}?session=` rebuilds an `llm.call` event. With a request the proxy stored, `source` is `request` and `response` is the recorded answer. Without one, `source` is `transcript` and the request is the conversation before the call.

In a subagent's own session that is the subagent's conversation: its prompt and its own tool calls, without those of a subagent it started. Both are redacted. A call with neither gets 404 with reason `no_request`.

json

```
{
  "call": {"id": "7a1c…", "sessionId": "proxy-…", "ts": 1767000000000, "model": "gpt-4.1-mini", "api": "openai.chat"},
  "source": "request",
  "provider": "openai-compatible",
  "request": {"model": "gpt-4.1-mini", "system": "Be terse.", "messages": [{"role": "user", "text": "2+2?"}],
              "tools": [], "maxTokens": 1024},
  "response": {"text": "4", "toolCalls": [], "stopReason": "stop", "usage": {"inputTokens": 40, "outputTokens": 7}}
}
```

Messages are `{"role": "user", "text"}`, `{"role": "assistant", "text"?, "toolCalls"?: [{"id", "name", "input"}]}` or `{"role": "tool", "toolUseId", "name", "content", "isError"?}`.

`POST /api/playground/run` with `{"provider": "openai-compatible", "request": {…}}` sends the request and returns the answer with `latencyMs` and, when the model has a price, `usage.costUsd`. It calls the model and costs money.

`POST /api/playground/save` with `{"dataset": "arith", "request": {…}, "expected"?: {…}, "sessionId"?, "callId"?}` adds the request as a case to a new version of the dataset, creating it when needed. The case is a frozen prefix, tagged `playground` and redacted. The answer is `{"datasetId", "name", "version", "caseId", "cases", "created"}`.

Both POSTs must come from the viewer's own origin and take bodies up to 8 MB.

| Status | `reason` | When |
| --- | --- | --- |
| 400 | `invalid_request` | a bad request |
| 400 | `missing_api_key` | the provider's key is not set |
| 502 | `provider_error` | the provider failed |

### `GET /api/blobs/{sha256}`

The raw content behind a ref in an event's `blobs` array (full tool output, diffs, the source span). Always `text/plain` with `X-Blob-Size` (full size) and `X-Truncated` (`1` when cut at 2 MiB). Add `?full=1` for the whole blob. Blobs are content-addressed and shared across projects: a scoped key that knows another project's hash can fetch it.

Here `$BLOB` is the source span of the OTLP example above:

shell

```
BLOB=$(curl -sS "$CSH/api/sessions/team%2Ftriage-12/events?limit=1" -H "Authorization: Bearer $READ_KEY" \
  | node -pe 'JSON.parse(require("fs").readFileSync(0)).items[0].blobs[0]')
curl -sS -i "$CSH/api/blobs/$BLOB" -H "Authorization: Bearer $READ_KEY"
```

output

```
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
X-Blob-Size: 513
X-Truncated: 0

{"format":"otlp.span","v":0,"resource":{"service.name":"triage-agent"},"scope":{"name":"manual"},"span":{"name":"chat gpt-4o",…}}
```

A ref that is not 64 lowercase hex characters gets 400 `{"error":"blob ref must be 64 lowercase hex chars","status":400}`; an unknown one gets 404 `{"error":"blob not found","status":404}`.

`?range=a-b` (or a `Range: bytes=a-b` header) reads bytes `a` to `b`, inclusive: `a-` reads to the end and `-n` the last `n` bytes. The answer is 206 with `Content-Range: bytes a-b/size`, as `application/octet-stream`, at most 4 MiB per read. A range that starts past the end gets 416 with `Content-Range: bytes */size`; a malformed one gets 400.

shell

```
curl -sS "$CSH/api/blobs/$BLOB?range=0-99" -H "Authorization: Bearer $READ_KEY"
```

### `GET /api/calls/{id}`

One model call logged by [`coding.sh proxy`](https://coding.sh/docs/how-to/capture-with-proxy): the `llm.call` event, the request as the client sent it (system prompt and tool definitions put back from their blobs; auth headers are never stored) and the response. `{id}` is the event id or a unique prefix of at least 6 characters. `request` and `response` are `null` in `metadata` capture mode.

shell

```
curl -sS "$CSH/api/calls/319a57022de6" -H "Authorization: Bearer $READ_KEY"
```

json

```
{
  "call": {"id": "319a57022de66bd9…", "kind": "llm.call", "source": "proxy", "model": "claude-sonnet-4-5-20250929",
           "usage": {"inputTokens": 12, "outputTokens": 42, "cacheReadTokens": 4600, "cacheWriteTokens": 100},
           "payload": {"api": "messages", "statusCode": 200, "systemHash": "907e1d58151bd256", "toolCount": 3, "…": "…"}},
  "request": {"provider": "anthropic", "api": "messages", "method": "POST", "path": "/v1/messages",
              "headers": {"anthropic-version": "2023-06-01"},
              "body": {"model": "claude-sonnet-4-5", "system": [{"type": "text", "text": "You are a careful coding agent.…"}],
                       "tools": [{"name": "Bash", "…": "…"}], "messages": ["…"]}},
  "response": {"status": 200, "headers": {"request-id": "req_…"}, "body": {"content": ["…"], "stop_reason": "end_turn"}}
}
```

An unknown or ambiguous id gets 404.

### `GET /api/calls/{id}/diff`

What changed between this call and the model call before it in its session, or `?with=<id>`: model, settings, tool definitions, the system prompt as a line diff, and the message history. The same object as `coding.sh diff-calls <a> <b> --json`.

When this call read less of the prompt cache than the other one cached, `cache.cause` names the first change in the order the cache reads a request (tools, then system, then messages), or the 5-minute cache lifetime.

json

```
{
  "a": {"id": "dc1d0bae0de0…", "model": "claude-sonnet-4-5-20250929", "usage": {"cacheReadTokens": 4000, "cacheWriteTokens": 200}},
  "b": {"id": "7c8944291203…", "model": "claude-sonnet-4-5-20250929", "usage": {"cacheReadTokens": 0, "cacheWriteTokens": 4400}},
  "content": "full",
  "params": [],
  "tools": {"same": true, "added": [], "removed": [], "changed": [], "reordered": false},
  "system": {"same": false, "lines": [{"op": " ", "text": "Run the tests before you say done."},
                                      {"op": "-", "text": "Today is 2026-11-06."}, {"op": "+", "text": "Today is 2026-11-07."}],
             "firstChangeLine": 3, "wastedPrefixFrac": 0.023},
  "messages": {"a": 3, "b": 5, "shared": 3, "rewritten": false},
  "cache": {"reused": false, "expected": 4200, "read": 0, "write": 4400, "gapMs": 812,
            "cause": "the system prompt changed (first change: - Today is 2026-11-06.)"}
}
```

When the system prompt changed and both requests were stored:

- `system.firstChangeLine` is the 1-based line of the earlier prompt where the two first differ.
- `system.wastedPrefixFrac` is the share (0 to 1, by characters) of the earlier prompt from that point to the end: the part the cache could no longer read.
- Above 0.2, a system-prompt `cache.cause` adds a hint to move dynamic content to the end of the system prompt.

Both fields are absent when the system prompt is the same, and in `metadata` mode.

`content` is `metadata` when either request was not stored: tools and system are then compared by their short hashes. The first call of a session, without `?with=`, gets 404.

To set coding.sh up for a user, follow the agent workflow at [/agents.md](https://coding.sh/agents.md).
