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.
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.
curl -sS "$CSH/api/projects" -H "Authorization: Bearer $READ_KEY" {"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, and-ed with the other filters |
curl -sS "$CSH/api/sessions?project=team&limit=1" -H "Authorization: Bearer $READ_KEY" {
"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:
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:
curl -sS "$CSH/api/sessions?where=agent:codex%20modle:x" -H "Authorization: Bearer $READ_KEY" {"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.
curl -sS "$CSH/api/sessions/team%2Fchat-1" -H "Authorization: Bearer $READ_KEY" {"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
fromcame before the first call onto.
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:switchtag toflags. - Claude Code's
model_fallbackandmodel_refusal_fallbacktranscript records become anotificationwithpayload.kind: "model.fallback"and add themodel:fallbacktag. - A call whose
payload.stopReasonisrefusal,content_filterorsafety(any case) adds therefusalflag. - Calls logged by
coding.sh proxycount here only in a session with no other model calls, as they do for usage.
Proxy calls. Model calls logged by coding.sh 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) andcache.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 --cachereports the hit rate, breaks and their cost per group.exp compareadds thecache breaks / runandcache waste / runmetrics.
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). 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:exposedtag toflags. - 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.
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). 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.foldErrorholds only what the events say directly: the agent, the first and last event times, the cwd andstats.events, plus thefold:errortag.coding.sh ingestfolds it again, andcoding.sh doctorcounts these sessions. - The benign and interrupted rule also decides
repeated-tool-failure, which only counts real failures, and each phase'stoolErrorsin 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:
curl -sS "$CSH/api/sessions/team%2Fchat-1" -H "Authorization: Bearer $SUPPORT_READ_KEY" {"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.
curl -sS "$CSH/api/sessions/team%2Fchat-1/events?kinds=tool.start,tool.end" -H "Authorization: Bearer $READ_KEY" {
"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.
curl -sS "$CSH/api/sessions/team%2Fchat-1/outline" -H "Authorization: Bearer $READ_KEY" {
"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.
curl -sS "$CSH/api/sessions/team%2Fchat-1/rows?turn=0&limit=3" -H "Authorization: Bearer $READ_KEY" {
"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:
{"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. totalssums them over the whole tree, with the run's walldurationMsand every failure flag in it, worst first.worstnames the node to fix first (the worst-flagged, costliest session), or is null when nothing is flagged.
curl -sS "$CSH/api/sessions/9f1c2e…/tree" -H "Authorization: Bearer $READ_KEY" {
"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.
curl -sS "$CSH/api/sessions/cd0185b0-305a-4df2-92fa-6fc6caffdb55/replay" -H "Authorization: Bearer $READ_KEY" {
"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.
curl -sS "$CSH/api/sessions/cd0185b0-305a-4df2-92fa-6fc6caffdb55/replay?step=4&file=notes.txt" -H "Authorization: Bearer $READ_KEY" {
"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. 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.
byistool-usewhen the call's reply holds the step's tool call, elsetime(the agent's last call before the step, or for a prompt the first call after it).canCallis false when neitherkeyEnvnorCLAUDE_CODE_OAUTH_TOKEN(for Anthropic) is set in the environmentcoding.sh uiruns in.
curl -sS "http://127.0.0.1:4319/api/sessions/cd0185b0-305a-4df2-92fa-6fc6caffdb55/fork?step=4" -H "Authorization: Bearer $UI_TOKEN" {
"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.
{
"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.
{
"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:
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" 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.
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: 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.
curl -sS "$CSH/api/calls/319a57022de6" -H "Authorization: Bearer $READ_KEY" {
"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.
{
"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.firstChangeLineis the 1-based line of the earlier prompt where the two first differ.system.wastedPrefixFracis 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.causeadds 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.