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.
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 «…».
curl -sS "$CSH/api/search?q=order&limit=1" -H "Authorization: Bearer $READ_KEY" {"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
wherequery narrows the totals, facets and histogram (itssort:andlimit:do not apply). A bad one answers 400 as on/api/sessions. - Each facet applies every filter except its own.
histogrambuckets session starts over at most the 90 days before the newest session.
curl -sS "$CSH/api/stats?project=team&agent=support-bot" -H "Authorization: Bearer $READ_KEY" {"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).
curl -sS "$CSH/api/metrics?since=7d&by=agent&tz=-120" -H "Authorization: Bearer $READ_KEY" {"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 |
curl -sS "$CSH/api/facets?keys=agent,tool&where=-flag:compaction" -H "Authorization: Bearer $READ_KEY" {"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, andparentandtypefor a subagent). - When the query has text, each item has a
snippetwith matches wrapped in«…». nextis 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, withtotalCapped: truepast that.
curl -sS "$CSH/api/events?where=tool:lookup_order&limit=1" -H "Authorization: Bearer $READ_KEY" {"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.
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).
curl -sS "$CSH/api/where" -H "Authorization: Bearer $READ_KEY" {"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.
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."}' {"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.
curl -sS "$CSH/api/labels?sessionId=team%2Fchat-1" -H "Authorization: Bearer $READ_KEY" {"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
tskept). - A stored score keeps its type: a value of another type is a 400 unless the body gives
dataTypeor the name has a config. - An event id an SDK client sent to
/v1/ingestworks astargetIdwith itssessionId(serve stores such ids hashed with the project). - A project-scoped key may only score its project's sessions and their events.
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."}' {"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.
curl -sS "$CSH/api/scores?sessionId=team%2Fchat-1" -H "Authorization: Bearer $READ_KEY" {"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.
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"}' {"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.
curl -sS "$CSH/api/experiments?dataset=refunds&name=baseline" -H "Authorization: Bearer $READ_KEY" {
"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}.
curl -sS "$CSH/api/experiments/$EXP_A" -H "Authorization: Bearer $READ_KEY" {
"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.
curl -sS "$CSH/api/compare?a=$EXP_A&b=$EXP_B" -H "Authorization: Bearer $READ_KEY" {
"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.
curl -sS "$CSH/api/benchmarks" -H "Authorization: Bearer $READ_KEY" {
"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.
curl -sS "$CSH/api/benchmarks/coding-sh-issues" -H "Authorization: Bearer $READ_KEY" {
"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.
curl -sS "$CSH/api/workflows?limit=1" -H "Authorization: Bearer $READ_KEY" {
"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.
curl -sS "$CSH/api/workflows/wf_45d472ff-246/gantt" -H "Authorization: Bearer $READ_KEY" {
"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): 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 |
curl -sS "$CSH/api/tasks?status=validated" -H "Authorization: Bearer $READ_KEY" {"items":[]} GET /api/update
Release check for the viewer's update banner. coding.sh serve never checks, so it always answers:
curl -sS "$CSH/api/update" -H "Authorization: Bearer $READ_KEY" {"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 uiingests in a background worker after it starts listening, so pages answer from what is already indexed whilefilesPendingcounts down.lastIngestAtis the end of the last finished pass (epoch ms),nullbefore the first.- Under
coding.sh ui,importingistruewhile past sessions are being imported (the background importcoding.sh initstarts 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:
curl -sS "$CSH/api/ingest" -H "Authorization: Bearer $READ_KEY" {"ingesting":false,"filesPending":0,"lastIngestAt":null}