---
title: "Read API: search, labels and evals · coding.sh docs"
description: "The /api routes for search, stats, facets, events, labels, experiments, compare, benchmarks, tasks, runs and bundles, update and ingest status."
canonical: https://coding.sh/docs/api/read/search
human_url: https://coding.sh/docs/api/read/search
markdown_url: https://coding.sh/docs/api/read/search.md
agents_url: https://coding.sh/agents.md
llms_url: https://coding.sh/llms.txt
updated: 2026-11-11
---

# Read API: search, labels and evals

The /api routes for search, stats, facets, events, labels, experiments, compare, benchmarks, tasks, runs and bundles, update and ingest status.

These routes take the same read key, errors and time rules as the rest of the [Read API](https://coding.sh/docs/api/read#read-api).

### `GET /api/search`

Full-text search over prompts, replies and tool I/O. `q` is required (up to 500 characters). Also takes `limit` (1 to 500, default 50), `project`, `scope`, `agent`, `model`, `flag`, `repo`, `since`, `until` and `where` (event keys test the matching event, session keys its session). Matches in `snippet` are wrapped in `«…»`.

shell

```
curl -sS "$CSH/api/search?q=order&limit=1" -H "Authorization: Bearer $READ_KEY"
```

json

```
{"items":[{"event":{"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"},"snippet":"lookup_«order»\nshipped"}]}
```

Without `q`: 400 `{"error":"q is required","status":400}`.

### `GET /api/stats`

Totals and facet counts for the session list. Takes `project`, `scope`, `agent`, `model`, `flag`, `repo`, `since`, `until` and `where`.

- A `where` query narrows the totals, facets and histogram (its `sort:` and `limit:` do not apply). A bad one answers 400 as on `/api/sessions`.
- Each facet applies every filter except its own.
- `histogram` buckets session starts over at most the 90 days before the newest session.

shell

```
curl -sS "$CSH/api/stats?project=team&agent=support-bot" -H "Authorization: Bearer $READ_KEY"
```

json

```
{"sessions":1,"subagents":0,"byAgent":{"triage-agent":1,"support-bot":1},"byFlag":{},"byRepo":{},"byModel":{"gpt-4o":1},"toolCalls":1,"toolErrors":0,"llmCalls":1,"costUsd":0.00251,"estimatedCostUsd":0.00251,"pricedSessions":1,"withTests":0,"testsFailing":0,"histogram":{"step":300000,"from":1793872800000,"buckets":[{"t":1793872800000,"n":1,"flagged":0}]}}
```

`byAgent` still lists every agent in `team`, because a facet skips its own filter. The totals apply all of them.

### `GET /api/metrics`

Sessions, failures, cost, tokens, tool calls and latency per day or week: the numbers behind the viewer's Explore page and `coding.sh stats --json`. Parameters:

| param | default | meaning |
| --- | --- | --- |
| `since`, `until` | 30 days before `until`; now | epoch ms, a date, or an age such as `7d`. A date without a zone (`2026-11-09`, `yesterday`) is midnight in `tz`, so `until=2026-11-10` keeps the whole of the 9th there |
| `bucket` | `day` | `day` or `week` (weeks start on Monday); at most 400 buckets |
| `by` | none | `agent`, `model`, `repo` or `flag`: one series per group |
| `groups` | 6 | how many groups get their own series (1 to 50); the rest add up in `other` |
| `tz` | 0 | minutes behind UTC, as `Date#getTimezoneOffset` gives (360 for UTC-6); buckets start at local midnight. Without `tz` the buckets are UTC days and a date is read in the server's zone, so send `tz` with a date |
| `scope` | `all` | `top` leaves out subagent sessions |
| `where`, `project` |  | as on `/api/sessions` |

A session counts in the bucket it started in.

| Field | Meaning |
| --- | --- |
| `failures` | Sessions with a flag that has no colon |
| `failureRate` | Failures over sessions |
| `toolErrorRate` | Tool errors over tool calls |
| `llmP95Ms` | The 95th percentile (nearest rank) of the sessions' own p95 model-call time. `null` in a bucket with no such session |
| `ttftP50Ms` | The median of the sessions' median time to first token. `null` in a bucket with no such session |
| `costUsd` | The sum over sessions that have a cost (`pricedSessions` of them) |
| `all` | Every session, whatever the split |
| `truncated` | Set by a store without a session-stats query, which reads only the first 100,000 sessions |

With `by=flag` a session with several flags counts in each series, and sessions without a flag form `(none)`.

shell

```
curl -sS "$CSH/api/metrics?since=7d&by=agent&tz=-120" -H "Authorization: Bearer $READ_KEY"
```

json

```
{"bucket":"day","by":"agent","since":1793232000000,"until":1793836800000,"tzOffsetMin":-120,"buckets":[1793224800000,"…"],"totals":{"sessions":12,"failures":3,"costUsd":4.1,"estimatedCostUsd":0,"pricedSessions":9,"tokens":1830000,"inputTokens":600000,"outputTokens":30000,"cacheReadTokens":1200000,"cacheWriteTokens":0,"toolCalls":161,"toolErrors":16,"llmCalls":139,"llmErrors":2,"failureRate":0.25,"toolErrorRate":0.099379,"llmP95Ms":30000,"ttftP50Ms":900},"all":{"key":"all","totals":{"…":"…"},"sessions":[2,0,"…"],"failures":[1,0,"…"],"costUsd":[1.65,0,"…"],"tokens":[339000,0,"…"],"toolCalls":[42,0,"…"],"toolErrors":[3,0,"…"],"llmCalls":[36,0,"…"],"llmP95Ms":[14000,null,"…"],"ttftP50Ms":[900,null,"…"]},"series":[{"key":"claude-code","…":"…"},{"key":"codex","…":"…"}]}
```

With `scope=all` and the same range and filters, `totals.sessions`, `toolCalls`, `toolErrors`, `llmCalls`, `costUsd` and `pricedSessions` equal `/api/stats`. The series' session counts equal its `byAgent`, `byModel` and `byFlag`.

A bad `bucket`, `by`, `tz`, `groups` or time, `since` not before `until`, more than 400 buckets, or a bad `where` answers 400 with the reason: `{"error":"bucket must be day or week","status":400}`.

### `GET /api/facets`

Session counts per value of each key in `keys`. Each key is counted over `where` without that key's own clauses, so a picked value does not hide the others.

| Parameter | Default | Meaning |
| --- | --- | --- |
| `keys` | `agent,model,repo,flag` | Comma-separated, at most 12, from `agent`, `model`, `provider`, `repo`, `branch`, `flag`, `skill`, `kind`, `tool` and `mcp` |
| `limit` | 20 | Values per key, 1 to 200 |
| `prefix` |  | Keeps only values that start with it, ignoring case, for autocomplete (up to 200 characters) |
| `where`, `project`, `scope`, `since`, `until` |  | As on `/api/sessions` |

shell

```
curl -sS "$CSH/api/facets?keys=agent,tool&where=-flag:compaction" -H "Authorization: Bearer $READ_KEY"
```

json

```
{"facets":{"agent":[{"value":"codex","count":2},{"value":"claude-code","count":1},{"value":"gemini","count":1}],"tool":[{"value":"Bash","count":3},{"value":"Edit","count":1},{"value":"Grep","count":1}]}}
```

An unknown key answers 400.

### `GET /api/events`

Events that match `where` across sessions, newest first.

| Parameter | Default | Meaning |
| --- | --- | --- |
| `where` |  | Event keys (`kind`, `tool`, `ok`, `mcp`, `error`, `event.duration`, text) test each event; session keys (`agent`, `repo`, `flag`, …) test its session |
| `limit` | 50 | 1 to 200 |
| `cursor` |  | `next` from the previous page |
| `since`, `until` |  | On the session's start, as on the list |
| `scope` | `all` | `top` leaves out subagent sessions' events |
| `project` |  | As on `/api/sessions` |

- Each item is the event with its payload clipped for a list row, plus `session` (`id`, `title`, `agent`, `model`, and `parent` and `type` for a subagent).
- When the query has text, each item has a `snippet` with matches wrapped in `«…»`.
- `next` is the cursor for the following page, and is absent on the last one.
- The first page also counts the matches: `total`, up to 10,000, with `totalCapped: true` past that.

shell

```
curl -sS "$CSH/api/events?where=tool:lookup_order&limit=1" -H "Authorization: Bearer $READ_KEY"
```

json

```
{"items":[{"id":"22270b1c…","v":0,"sessionId":"team/chat-1","ts":1793872802400,"agent":"support-bot","kind":"tool.end","durationMs":400,"source":"sdk","project":"team","payload":{"tool":"lookup_order","toolUseId":"t1","ok":true,"output":"shipped"},"session":{"id":"team/chat-1","title":"Where is my order?","agent":"support-bot","model":"gpt-4o"}}],"total":1,"totalCapped":false}
```

A bad `where` or `cursor` answers 400. A store plugin with no event query path answers 400 with `reason: "unsupported_store"`.

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

One event by its id, whole, as stored (a row of `/rows` carries a line; this is what it opens to). An id the index does not hold, or one in a session outside the key's project, gets 404.

shell

```
curl -sS "$CSH/api/events/9a1f03c2…" -H "Authorization: Bearer $READ_KEY"
```

### `GET /api/where`

The `--where` keys, for the viewer's query bar to complete. Each item has `name`, `level` (`session`, `event` or `query`), `type`, `doc` and `example`; `facet: true` when `/api/facets` can list its values, and `values` when the key takes a few fixed ones (`ok`, `is`, `has`, `sort`, and times such as `7d` for `since` and `until`).

shell

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

json

```
{"keys":[{"name":"agent","level":"session","type":"text","doc":"Agent id","example":"agent:codex","facet":true},{"name":"ok","level":"event","type":"bool","doc":"Tool call or model call succeeded","example":"ok:false","values":[{"value":"true"},{"value":"false"}]},…]}
```

### `POST /api/labels`

Adds or replaces a label. Body (JSON, up to 1 MB):

| Field | Meaning |
| --- | --- |
| `sessionId` | Required |
| `value` | `pass`, `fail` or `skip` |
| `criterion` | 1-100 characters of letters, digits and `_.:-`. Default: `outcome` |
| `note` | Optional, up to 4,000 characters |

There is one label per session, criterion and labeller, so labelling again replaces it. `by` is the basic-auth user name, the read key's `name` from `[serve.viewer] read_keys`, or else `reader:` and the first 12 hex characters of the key's sha256 (never the key). So each read key keeps its own labels.

shell

```
curl -sS -X POST "$CSH/api/labels" \
  -H "Authorization: Bearer $READ_KEY" \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"team/chat-1","criterion":"resolved","value":"pass","note":"Answered with the tracking status."}'
```

json

```
{"id":"1cec1aa4e5d701ef71b2682544643f4a214369b8602b84be04a77bb431521d75","sessionId":"team/chat-1","criterion":"resolved","value":"pass","by":"reader:3f9a1c0b7d2e","ts":1794030915938,"note":"Answered with the tracking status."}
```

| Status | When | Body |
| --- | --- | --- |
| 201 | stored | the label |
| 400 | bad field or invalid JSON | `{"error":"value must be pass, fail or skip","status":400}` |
| 403 | an `Origin` header that is not this server | `{"error":"forbidden origin","status":403}` |
| 404 | session missing or outside the key's project | `{"error":"session not found","status":404}` |
| 413 | body over 1 MB | `{"error":"request body exceeds 1 MB","status":413}` |
| 415 | not `application/json` | `{"error":"content-type must be application/json","status":415}` |

### `GET /api/labels`

Human labels. Filters: `sessionId` and `criterion`. A scoped key sees only labels on its project's sessions.

shell

```
curl -sS "$CSH/api/labels?sessionId=team%2Fchat-1" -H "Authorization: Bearer $READ_KEY"
```

json

```
{"items":[{"id":"1cec1aa4…","sessionId":"team/chat-1","criterion":"resolved","value":"pass","by":"reader:3f9a1c0b7d2e","ts":1794030915938,"note":"Answered with the tracking status."}]}
```

### `POST /api/scores`

Adds or replaces a typed score on a session, an event, a dataset case or an eval run. Labels are scores too: a person's pass or fail label is a boolean score, and `GET /api/labels` reads those back. Body (JSON, up to 1 MB):

| Field | Meaning |
| --- | --- |
| `target` | `session` (default), `event`, `case` or `run` |
| `targetId` | Id of what is scored. For a session, `sessionId` works in its place |
| `name` | 1-100 characters of letters, digits and `_.:/-` (or the config's name, with `configId`) |
| `value` | A number (`numeric`), `true` or `false` (`boolean`, 0 and 1 work too), or a string (`categorical`, `text`) |
| `dataType` | `numeric`, `boolean`, `categorical` or `text`. Default: the config's, else the value's type. Needed to change the type of a stored score whose name has no config |
| `source` | `human`, `judge`, `code`, `api` (default) or `monitor` |
| `sessionId` | The session a case or run belongs to. An event's session is looked up; a `sessionId` that is not the event's session is a 400 |
| `configId` | Check the value against this config. Default: the config with the score's `name`, if any |
| `judgeVersion`, `calibrated` | The judge version that gave the score, and whether it passed calibration |
| `comment` | Up to 4,000 characters |
| `ts` | Epoch milliseconds. Default: now |
| `id` | Score id. Default: one score per target, name, source and caller, so scoring again replaces it |

`by` is the caller, as for labels. A value outside its config's `min`/`max` or `categories` gets 400.

- Posting the same id with the same content again changes nothing and answers 200 with the stored score (its first `ts` kept).
- A stored score keeps its type: a value of another type is a 400 unless the body gives `dataType` or the name has a config.
- An event id an SDK client sent to `/v1/ingest` works as `targetId` with its `sessionId` (serve stores such ids hashed with the project).
- A project-scoped key may only score its project's sessions and their events.

shell

```
curl -sS -X POST "$CSH/api/scores" \
  -H "Authorization: Bearer $READ_KEY" \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"team/chat-1","name":"helpfulness","value":4,"source":"judge","comment":"Gave the tracking status."}'
```

json

```
{"id":"2c72f6d59487cd99f4ecc55472f2470c9b717061871e32fce322fb890bd84f90","target":"session","targetId":"team/chat-1","sessionId":"team/chat-1","name":"helpfulness","dataType":"numeric","value":4,"source":"judge","by":"reader:b370186b2ba8","ts":1794197173401,"configId":"helpfulness","comment":"Gave the tracking status."}
```

| Status | When | Body |
| --- | --- | --- |
| 201 | stored | the score |
| 200 | the id already held a score: replaced, or unchanged when the content is the same | the score |
| 400 | bad field, a value outside the config, a value of another type than the stored score, a `sessionId` that is not the event's session, invalid JSON | `{"error":"value 7 is above helpfulness's maximum 5","status":400}` |
| 403 | an `Origin` header that is not this server | `{"error":"forbidden origin","status":403}` |
| 404 | session or event missing, or outside the key's project | `{"error":"session not found: team/chat-9","status":404}` |
| 413 | body over 1 MB | `{"error":"request body exceeds 1 MB","status":413}` |
| 415 | not `application/json` | `{"error":"content-type must be application/json","status":415}` |
| 501 | a Postgres or ClickHouse store (scores are SQLite only for now) | `{"error":"the postgres store does not keep scores yet (use the default SQLite store)","status":501}` |

### `GET /api/scores`

Scores, newest first. Filters: `target`, `targetId`, `sessionId` (the session and its events, cases and runs scored with it), `name`, `source`, `since` and `until` (epoch milliseconds), and `limit` (1 to 1,000, default 100). A scoped key sees only scores on its project's sessions.

shell

```
curl -sS "$CSH/api/scores?sessionId=team%2Fchat-1" -H "Authorization: Bearer $READ_KEY"
```

json

```
{"items":[{"id":"1b6c0745…","target":"event","targetId":"b698dcca…","sessionId":"team/chat-1","name":"grounded","dataType":"boolean","value":true,"source":"code","by":"reader:b370186b2ba8","ts":1794197173426},{"id":"2c72f6d5…","target":"session","targetId":"team/chat-1","sessionId":"team/chat-1","name":"helpfulness","dataType":"numeric","value":4,"source":"judge","by":"reader:b370186b2ba8","ts":1794197173401,"configId":"helpfulness","comment":"Gave the tracking status."}]}
```

### `GET /api/score-configs`, `POST /api/score-configs`

A config fixes one score name's type and allowed values. `GET` lists them sorted by name. The `POST` body:

| Field | Meaning |
| --- | --- |
| `name`, `dataType` | Required |
| `min`, `max` | For `numeric`: optional, inclusive |
| `categories` | For `categorical`: required |
| `description` | Optional |
| `id` | Optional. Default: the name |

- One config per name: another id with the same name is a 409.
- Posting a config again replaces it (201 when new, 200 otherwise).
- Configs are shared by every project, so a project-scoped key can read them but gets 403 on a POST.

shell

```
curl -sS -X POST "$CSH/api/score-configs" \
  -H "Authorization: Bearer $READ_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"helpfulness","dataType":"numeric","min":0,"max":5,"description":"How much the answer helped"}'
```

json

```
{"id":"helpfulness","name":"helpfulness","dataType":"numeric","min":0,"max":5,"description":"How much the answer helped","ts":1794197173384}
```

### `GET /api/experiments`

Dataset experiments written by `coding.sh exp run`, newest first, without their per-case runs. Parameters: `limit` (1 to 500, default 50), `dataset` (dataset id) and `name`. Empty for a scoped key.

shell

```
curl -sS "$CSH/api/experiments?dataset=refunds&name=baseline" -H "Authorization: Bearer $READ_KEY"
```

json

```
{
  "items": [{
    "id": "exp_01M6T9E8YNKKKEX4GTT1TY5XQ5", "name": "baseline", "status": "completed",
    "dataset": {"id": "refunds", "name": "refunds", "version": "09d855430b57ff15"},
    "task": {"kind": "command", "ref": "echo 4"},
    "graders": [{"id": "exact", "version": "48fe67515f989183", "kind": "exact"}],
    "trials": 1, "createdAt": 1794031166421, "finishedAt": 1794031166436,
    "summary": {"n": 3, "cases": 3, "passed": 1, "errors": 0, "ungraded": 0,
      "passRate": {"p": 0.3333333333333333, "lo": 0.06149194472039621, "hi": 0.7923403991979522}, "ciMethod": "wilson", …},
    "runCount": 3
  }]
}
```

These fields are present only when they apply:

| Field | When | Holds |
| --- | --- | --- |
| `samples`, `summary.caseVerdicts` | `exp run --samples adaptive` | `"samples": "adaptive"`, and verdicts `passed`, `failed`, `tieBreaks`. The `passRate` interval is then biased, so use the verdicts |
| `regression` | `--stop-on-regression` stopped the run (status `cancelled`) | `{caseId, baseline}` |
| `metadata.checkpoint` | A partial record written while the run was going (status `cancelled`) | `done`, `total`, `at` |
| `metadata.selection` | A run of part of the dataset | `from` (`{kind: "failing"\|"errors", experiment}`), `cases`, `sample`, `seed`, `firstN`, `selected`, `of` |
| `metadata.seq` | `exp run --seq K` | `k`, `feedback`, `history` |
| `summary.seq` | `exp run --seq K` | `k`, `feedback`, `history`, `curve` (`[{k, rate, lo, hi}]`, Seq@1..K), `egs`, `lgs`, `unchangedRate`, `costToSolve`; with 2+ trials also `passAtK` (`[{k, rate}]`), `front` (`[{protocol, k, cost, success, overall}]`) and `costUnit` (`usd` or `attempts`) |
| `seq` on each run | `exp run --seq K` | `k`, `solvedAt`, `feedback`, `history`, `attempts` (`[{pass, latencyMs, costUsd?, criticCostUsd?, unchanged?, error?, feedback?}]`) |

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

One experiment with its case runs (up to 5,000; `runsTruncated` says when more exist). Outputs over 4,000 characters are cut and marked `outputTruncated`. Ids come from `coding.sh exp list`. Unknown ids, and any id for a scoped key, get 404 `{"error":"experiment not found","status":404}`.

shell

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

json

```
{
  "id": "exp_01M6T9E8YNKKKEX4GTT1TY5XQ5", "name": "baseline", "status": "completed", …, "runCount": 3,
  "runs": [
    {"caseId": "c_992c448def7d1479", "trial": 1, "latencyMs": 11.42, "grades": [{"graderId": "exact", "result": {"score": 1, "pass": true}}], "pass": true, "startedAt": 1794031166424, "output": 4},
    {"caseId": "c_47dd42650c395e5c", "trial": 1, "latencyMs": 6.63, "grades": [{"graderId": "exact", "result": {"score": 0, "pass": false, "reason": "expected \"6\", got \"4\""}}], "pass": false, "startedAt": 1794031166429, "output": 4}
  ],
  "runsTruncated": false
}
```

### `GET /api/compare`

Paired comparison of two experiments: `a` is the baseline, `b` the candidate. Both are required. The server computes the statistics. `partial` lists sides that were cancelled.

shell

```
curl -sS "$CSH/api/compare?a=$EXP_A&b=$EXP_B" -H "Authorization: Bearer $READ_KEY"
```

json

```
{
  "a": {"id": "exp_01M6T9E8YNKKKEX4GTT1TY5XQ5", "name": "baseline", "passRate": 0.3333333333333333},
  "b": {"id": "exp_01M6T9E94FSMK542B6WQHNCDMR", "name": "candidate", "passRate": 1},
  "ungraded": {"a": 0, "b": 0}, "paired": 3, "onlyInA": [], "onlyInB": [],
  "diff": {"mean": 0.6666666666666666, "lo": 0, "hi": 1, "iters": 4000},
  "mcnemar": {"b": 0, "c": 2, "n": 2, "p": 0.5}, "signFlip": {"p": 0.5, "units": 3, "method": "exact"}, "verdict": "inconclusive",
  "timing": {"interleaved": false, "gapMs": 81, "warning": "A and B ran one after the other, not interleaved, …"},
  "regressions": [], "improvements": [{"caseId": "c_47dd42650c395e5c", "a": 0, "b": 1, "delta": 1}, …],
  "graders": [{"id": "exact", "n": 3, "meanA": 0.3333333333333333, "meanB": 1, "diff": {"mean": 0.6666666666666666, "lo": 0, "hi": 1}}],
  "attrition": {"a": {"runs": 3, "completed": 3, "errored": 0, "infra": 0, "agent": 0, "ungraded": 0, "missing": 0, "lost": 0}, "b": {…}, "p": 1},
  "power": {"alpha": 0.05, "power": 0.8, "n": 3, "mde": 0.9338617396156048, "minPaired": 200, "warnings": […], …},
  "posterior": {"pBetter": 0.875, "mean": 0.2857142857142857, "lo": -0.24060826256615866, "hi": 0.7345975081216372, "prior": 1, "draws": 20000},
  "partial": []
}
```

| Field | Meaning |
| --- | --- |
| `signFlip` | The exact two-sided sign-flip test on the per-case differences (per-cluster sums when clustered). `units` counts cases or clusters; `method` is `exact` up to 20 changed units and `monte-carlo` above |
| `verdict` | `better` or `worse` only when `diff` excludes zero and `signFlip.p` is below 0.05 |
| `power.exact` | The exact McNemar design when some case flipped: `{"n", "discordance", "alpha", "power", "minDiscordant", "ceiling", "mde"}`. `ceiling` is the power at the largest possible gap |
| `timing.interleaved` | Also true for two cells of one `exp run` matrix (both carry the same `metadata.matrix.id`) |

When `power.exact.mde` is `null`, no gap reaches `power`, and `power.warnings` has a `no-power` entry.

These fields are present only when they apply:

| Field | When |
| --- | --- |
| `graders[].versionChanged` | The sides were graded with different grader versions: `{"a","b"}` |
| `gradersOnlyIn` | The sides were graded by different graders: `{"a":[…],"b":[…]}` |
| `instrumentWarning` | The sides were graded differently; the text says how |
| `ungradedCases` | Cases ran on both sides but have no graded run on one |
| `selectionWarning` | One side ran only on the other's failures (`exp run --failing`). The verdict is then `inconclusive` |
| `seqK` | Both sides ran `--seq` with the same K and feedback: `k`, `feedback`, `n`, `a`, `b`, `diff` |
| `seqNote` | Only one side ran `--seq`, or their K or feedback differ |

A missing or malformed id gets 400 `{"error":"a and b must be experiment ids","status":400}`; an unknown one gets 404 `{"error":"experiment not found","status":404}`.

### `GET /api/benchmarks`

Daily benchmarks written by `coding.sh bench run`, read from `~/.coding.sh/benchmarks/<name>/runs`. Newest first. Each item has the latest run's leaderboard and the last 30 daily pass rates per contender. `coding.sh ui` reads the local benchmark folder; `coding.sh serve` has none, so it answers an empty list, as does a scoped key.

shell

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

json

```
{
  "items": [{
    "name": "coding-sh-issues", "description": "Fresh coding.sh issues each day; the agent's patch has to pass the repo's tests.",
    "counted": 24, "total": 24, "reference": "claude-code",
    "latest": {"id": "bench_codingshissues20261107", "date": "2026-11-07", "cases": 30, "mode": "scheduled", "status": "complete", "dataset": "coding-sh-issues@261107"},
    "contenders": [{"name": "codex", "model": "gpt-6.1-sol", "place": 1, "n": 30, "passed": 19, "passRate": {"p": 0.6333, "lo": 0.4551, "hi": 0.7813}}, …],
    "trend": {"claude-code": [0.6, 0.4, 0.7333, …], "codex": [0.4667, 0.3333, …]}
  }]
}
```

`counted` is the number of runs on the current protocol; `total` counts every run.

### `GET /api/benchmarks/{name}`

The same report `coding.sh bench show <name> --json` prints: the latest run, daily history per metric, the gap to the reference contender, the pooled gap, difficulty, the anchor set, cases whose content changed, the seed check and the failed cases.

Two things differ: `cases` is left out, and each failure carries `hasSession` (whether the session is in this index, so the page can link to it) in place of `sessionUrl`.

shell

```
curl -sS "$CSH/api/benchmarks/coding-sh-issues" -H "Authorization: Bearer $READ_KEY"
```

json

```
{
  "v": 1, "bench": "coding-sh-issues", "reference": "claude-code", "window": 28,
  "latest": {"id": "bench_codingshissues20261107", "date": "2026-11-07", "summaries": […], "comparisons": […], "anchor": {…}, …},
  "history": {"pass": {"codex": [{"date": "2026-10-15", "run": "bench_codingshissues20261015", "value": 0.4667, "lo": 0.3023, "hi": 0.6386, "n": 30, "segment": 0}, …]}, "cost": {…}, …},
  "vsReference": {"codex": [{"date": "2026-10-15", "value": -0.1333, "lo": -0.3333, "hi": 0.0667, …}, …]},
  "protocolBreaks": [15],
  "pooled": {"codex": {"k": 9, "cases": 270, "random": {"mean": -0.0852, "lo": -0.1918, "hi": 0.0215}, "i2": 0.2018, …}},
  "difficulty": {"fresh": [{"date": "2026-10-15", "value": 0.5111, "n": 30}, …], "anchor": […]},
  "anchor": {"version": "v1", "cases": 10, "sha256": "…", "frozenBy": "bench_…", "history": {…}, "vsReference": {…}},
  "contentChanges": [{"key": "anchor-2", "anchor": true, "from": {"date": "2026-10-15", …}, "to": {"date": "2026-11-03", …}}],
  "seedCheck": {"ok": true, "publishedBy": {"run": "bench_codingshissues20261106", "date": "2026-11-06"}, "checks": […]},
  "failures": {"codex": [{"caseId": "2026-11-07-004", "failure": "wrong", "detail": "tsc: 1 error in src/viewer/server.ts", "latencyMs": 223519, "sessionId": "…", "hasSession": true}, …]},
  "unsolved": [{"caseId": "2026-11-07-011", "input": "…"}]
}
```

A name outside `[a-z0-9][a-z0-9._-]*` (up to 63 characters) gets 400 `{"error":"invalid benchmark name","status":400}`; a benchmark with no runs gets 404 `{"error":"benchmark not found","status":404}`.

### `GET /api/workflows`

Workflow runs recorded by `coding.sh workflows`, newest first: the list `coding.sh workflows list --json` prints. Filters: `limit` (1 to 500, default 50), `framework` and `name`. `coding.sh ui` reads the local workflow store; `coding.sh serve` has none, so it answers an empty list, as does a scoped key.

shell

```
curl -sS "$CSH/api/workflows?limit=1" -H "Authorization: Bearer $READ_KEY"
```

json

```
{
  "runs": [{
    "id": "wf_45d472ff-246", "framework": "claude-code-workflow", "name": "improve", "status": "ok",
    "startedAt": "2026-11-08T09:12:04.000Z", "endedAt": "2026-11-08T10:40:51.000Z", "wallMs": 5327000,
    "agents": 14, "costUsd": 31.2, "tokens": 18450210, "variant": null, "experimentId": null,
    "caseId": null, "resumedFrom": null, "hostSessionId": "…", "tags": []
  }]
}
```

A `limit` outside 1 to 500 or a malformed `framework` gets 400.

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

One run, as `coding.sh workflows show <run> --json` prints it: `run` (the list fields plus `stats` and, when set, `concurrencyCap`) and `nodes` (every node but subagent, tool and LLM nodes, as `show` without `--tree`). `/api/workflows/{id}/gantt` answers the timeline `workflows show --gantt --json` prints: `phases` with one row per node (`start`, `end`, `status`, `critical`), and `concurrency` over time.

shell

```
curl -sS "$CSH/api/workflows/wf_45d472ff-246/gantt" -H "Authorization: Bearer $READ_KEY"
```

json

```
{
  "runId": "wf_45d472ff-246", "startedAt": 1794128524000, "endedAt": 1794133851000, "cap": 8,
  "phases": [{"name": "Build", "rows": [{"nodeId": "…", "label": "store", "start": 1794128530000, "end": 1794129912000, "status": "ok", "critical": true, "costUsd": 2.1}, …]}],
  "concurrency": [{"t": 1794128530000, "running": 6}, …]
}
```

An unknown run gets 404, as does any other path under it, and on `coding.sh serve` every run.

### `GET /api/tasks`, `GET /api/runs`, `GET /api/bundles`

Eval tasks (`coding.sh eval`), runs (`coding.sh compare`) and harness bundles (`coding.sh harness`). All three answer `{"items":[…]}` and are empty for a scoped key.

| Route | Filters | Notes |
| --- | --- | --- |
| `/api/tasks` | `status` (`draft`, `validated` or `archived`), `tag` |  |
| `/api/runs` | `experimentId`, `taskId`, `bundleId` | Run fields below |
| `/api/bundles` |  | `id`, `name`, `agent`, `model`, `createdAt` and `note` only; overlay files stay behind `/api/blobs` |

A run carries these fields when they apply:

| Field | Meaning |
| --- | --- |
| `cellKey` | The content key `compare --reuse` matches on. Absent on older runs |
| `reusedFrom` | The id of the run `compare --reuse` copied this one from |
| `tamper` | Set when the run's diff was checked: `flagged`, `flags` (each with `kind`, `path`, `why` and `detail`) and `addedTests`, the existing test files the agent only added new test cases to (absent when none) |
| `verifierOutput` | The end of what the verifier printed: `stdout` and `stderr`, each the last 4096 characters, colour codes removed, redacted with `capture.redact` and `capture.redactPatterns`. An empty stream is absent, and so is the field when both were |
| `repair` | From `compare --repair`: `signal` (`bit` or `names`), `mode` (`resume` or `fresh`), `limit`, `rounds`, `pass`, `falseAlarm` and `falseAlarmFired` with `--false-alarm`, and `skipped` (`error` or `timeout`) when the first attempt could not be repaired |
| `repair.pass` | The outcome after the last round. `pass` on the run stays the first attempt's |
| `repair.rounds[]` | `n`, `pass`, `verifierExit`, `verifierOutput`, `durationMs`, `usage`, `sessionId`, `diffBlob`; `unchanged` when the round changed no file, `tamper` when its diff tampers with tests, `namesParsed` with signal `names` |
| `agentVersion` | The agent release the run started on (the adapter's detected version). Absent when the adapter can't tell, and on older runs |
| `isolation` | How the verifier's tree was built ([verifier isolation](https://github.com/jackulau/coding.sh/blob/main/docs/guides/evals.md#verifier-isolation)): `carried`, `stripped` and `skipped`, below. Absent with `--no-isolation`, on a task without a verifier command, and on older runs |
| `isolation.carried` | The agent's changes applied to the fresh start tree |
| `isolation.stripped` | Each change left out, with `path`, `why` (`hook`, `config`, `scripts`, `verifier`, `dependency`, `symlink` or `special`) and `change` (`add`, `modify` or `delete`) |
| `isolation.skipped` | Why the tree could not be isolated |
| `traceId` | The trace id the agent got in `TRACEPARENT` with `compare --otel` |
| `sequence` | The run's place (`stream`, `step`) with `compare --sequential` |

shell

```
curl -sS "$CSH/api/tasks?status=validated" -H "Authorization: Bearer $READ_KEY"
```

json

```
{"items":[]}
```

### `GET /api/update`

Release check for the viewer's update banner. `coding.sh serve` never checks, so it always answers:

shell

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

json

```
{"current":"","latest":null,"updateAvailable":false,"url":null,"command":"","checkedAt":null}
```

### `GET /api/ingest`

Progress of the local spool ingest, for the viewer's "Indexing N files…" pill.

- `coding.sh ui` ingests in a background worker after it starts listening, so pages answer from what is already indexed while `filesPending` counts down.
- `lastIngestAt` is the end of the last finished pass (epoch ms), `null` before the first.
- Under `coding.sh ui`, `importing` is `true` while past sessions are being imported (the background import `coding.sh init` starts when its 8 seconds run out). The pill and the empty session list then say "Importing past sessions…".

`coding.sh serve` ingests events as they arrive, so it always answers:

shell

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

json

```
{"ingesting":false,"filesPending":0,"lastIngestAt":null}
```

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