Docs Search and evals API

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 «…».

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:

paramdefaultmeaning
since, until30 days before until; nowepoch 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
bucketdayday or week (weeks start on Monday); at most 400 buckets
bynoneagent, model, repo or flag: one series per group
groups6how many groups get their own series (1 to 50); the rest add up in other
tz0minutes 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
scopealltop leaves out subagent sessions
where, projectas on /api/sessions

A session counts in the bucket it started in.

FieldMeaning
failuresSessions with a flag that has no colon
failureRateFailures over sessions
toolErrorRateTool errors over tool calls
llmP95MsThe 95th percentile (nearest rank) of the sessions' own p95 model-call time. null in a bucket with no such session
ttftP50MsThe median of the sessions' median time to first token. null in a bucket with no such session
costUsdThe sum over sessions that have a cost (pricedSessions of them)
allEvery session, whatever the split
truncatedSet 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.

ParameterDefaultMeaning
keysagent,model,repo,flagComma-separated, at most 12, from agent, model, provider, repo, branch, flag, skill, kind, tool and mcp
limit20Values per key, 1 to 200
prefixKeeps only values that start with it, ignoring case, for autocomplete (up to 200 characters)
where, project, scope, since, untilAs 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.

ParameterDefaultMeaning
whereEvent keys (kind, tool, ok, mcp, error, event.duration, text) test each event; session keys (agent, repo, flag, …) test its session
limit501 to 200
cursornext from the previous page
since, untilOn the session's start, as on the list
scopealltop leaves out subagent sessions' events
projectAs 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):

FieldMeaning
sessionIdRequired
valuepass, fail or skip
criterion1-100 characters of letters, digits and _.:-. Default: outcome
noteOptional, 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."}
StatusWhenBody
201storedthe label
400bad field or invalid JSON{"error":"value must be pass, fail or skip","status":400}
403an Origin header that is not this server{"error":"forbidden origin","status":403}
404session missing or outside the key's project{"error":"session not found","status":404}
413body over 1 MB{"error":"request body exceeds 1 MB","status":413}
415not 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):

FieldMeaning
targetsession (default), event, case or run
targetIdId of what is scored. For a session, sessionId works in its place
name1-100 characters of letters, digits and _.:/- (or the config's name, with configId)
valueA number (numeric), true or false (boolean, 0 and 1 work too), or a string (categorical, text)
dataTypenumeric, 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
sourcehuman, judge, code, api (default) or monitor
sessionIdThe 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
configIdCheck the value against this config. Default: the config with the score's name, if any
judgeVersion, calibratedThe judge version that gave the score, and whether it passed calibration
commentUp to 4,000 characters
tsEpoch milliseconds. Default: now
idScore 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."}
StatusWhenBody
201storedthe score
200the id already held a score: replaced, or unchanged when the content is the samethe score
400bad 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}
403an Origin header that is not this server{"error":"forbidden origin","status":403}
404session or event missing, or outside the key's project{"error":"session not found: team/chat-9","status":404}
413body over 1 MB{"error":"request body exceeds 1 MB","status":413}
415not application/json{"error":"content-type must be application/json","status":415}
501a 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:

FieldMeaning
name, dataTypeRequired
min, maxFor numeric: optional, inclusive
categoriesFor categorical: required
descriptionOptional
idOptional. 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:

FieldWhenHolds
samples, summary.caseVerdictsexp 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.checkpointA partial record written while the run was going (status cancelled)done, total, at
metadata.selectionA run of part of the datasetfrom ({kind: "failing"|"errors", experiment}), cases, sample, seed, firstN, selected, of
metadata.seqexp run --seq Kk, feedback, history
summary.seqexp run --seq Kk, 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 runexp run --seq Kk, 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": []
}
FieldMeaning
signFlipThe 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
verdictbetter or worse only when diff excludes zero and signFlip.p is below 0.05
power.exactThe exact McNemar design when some case flipped: {"n", "discordance", "alpha", "power", "minDiscordant", "ceiling", "mde"}. ceiling is the power at the largest possible gap
timing.interleavedAlso 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:

FieldWhen
graders[].versionChangedThe sides were graded with different grader versions: {"a","b"}
gradersOnlyInThe sides were graded by different graders: {"a":[…],"b":[…]}
instrumentWarningThe sides were graded differently; the text says how
ungradedCasesCases ran on both sides but have no graded run on one
selectionWarningOne side ran only on the other's failures (exp run --failing). The verdict is then inconclusive
seqKBoth sides ran --seq with the same K and feedback: k, feedback, n, a, b, diff
seqNoteOnly 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.

RouteFiltersNotes
/api/tasksstatus (draft, validated or archived), tag
/api/runsexperimentId, taskId, bundleIdRun fields below
/api/bundlesid, name, agent, model, createdAt and note only; overlay files stay behind /api/blobs

A run carries these fields when they apply:

FieldMeaning
cellKeyThe content key compare --reuse matches on. Absent on older runs
reusedFromThe id of the run compare --reuse copied this one from
tamperSet 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)
verifierOutputThe 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
repairFrom 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.passThe 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
agentVersionThe agent release the run started on (the adapter's detected version). Absent when the adapter can't tell, and on older runs
isolationHow 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.carriedThe agent's changes applied to the fresh start tree
isolation.strippedEach change left out, with path, why (hook, config, scripts, verifier, dependency, symlink or special) and change (add, modify or delete)
isolation.skippedWhy the tree could not be isolated
traceIdThe trace id the agent got in TRACEPARENT with compare --otel
sequenceThe 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}