REST API reference
Every route coding.sh serve answers, with auth and examples: ingest, OTLP, the model API proxy, health and metrics.
Every HTTP route that coding.sh serve answers, with auth, parameters, responses and errors.
The design notes behind these routes live in PRODUCTION.md. Server settings are in config.md, and a full team setup is in self-host-team.md.
Basics
Base URL. One process, one port. The default is http://127.0.0.1:4318, so an OTEL_EXPORTER_OTLP_ENDPOINT base URL works as is. Serve speaks plain HTTP; put TLS on the platform or a proxy in front of it.
Auth. Each route takes one kind of credential.
| Credential | How to send it | Opens |
|---|---|---|
Ingest key csh_ik_… | Authorization: Bearer csh_ik_… | /v1/* ingest routes |
Read key csh_rk_… | Authorization: Bearer csh_rk_…, the csh_rk cookie, or /?t=<key> once in a browser | /, /api/*; /metrics only with a * key |
| Basic auth | Authorization: Basic … for the user in CODING_SH_BASIC_AUTH | /, /api/*, /metrics |
| none | /healthz, /readyz |
- Create keys with
coding.sh serve keygen. The key goes to stdout once; the server keeps only its sha256. - Ingest keys cannot read and read keys cannot ingest. Each gets 401.
- On a loopback bind with no keys configured, ingest is open and lands in project
default, and reads are open too. - A non-loopback bind with no credentials at all makes one ingest key and one read key on first start. It keeps them in
<CODING_SH_HOME>/serve-keys.jsonand prints them on every start. - A non-loopback bind with only some credentials (an ingest key but no read key, say) refuses to start unless you pass
--insecure.
Projects. The ingest key decides the project of everything it sends; a client cannot pick one.
- In every project other than
default, session ids are stored as<project>/<sessionId>:chat-1sent with ateamkey becomesteam/chat-1. - Percent-encode the slash when an id goes in a path:
/api/sessions/team%2Fchat-1. The raw form/api/sessions/team/chat-1answers 404 with a hint to encode it. - A read key scoped to one project sees only that project. Lists are filtered, other sessions answer 404, and eval data (tasks, runs, bundles, experiments) comes back empty.
- A
*read key sees every project and can filter with?project=.
Content types. Requests: application/json for /v1/ingest, POST /api/labels, POST /api/scores and POST /api/score-configs; application/json or application/x-protobuf for OTLP. Ingest bodies may use Content-Encoding gzip, deflate or br. Responses are JSON, except the viewer page (HTML), /metrics (Prometheus text), blobs (text/plain) and OTLP protobuf requests (answered in protobuf).
Limits.
| Limit | Value |
|---|---|
| Ingest body, before and after decompression | 16 MiB (16777216 bytes) |
Events per /v1/ingest request | 10,000 |
| Ingest queue | 50,000 events or 128 MiB (CODING_SH_QUEUE_MAX_EVENTS, CODING_SH_QUEUE_MAX_BYTES) |
POST /api/labels, /api/scores, /api/score-configs body | 1 MB |
| Blob response | 2 MiB unless ?full=1 |
| Request headers / whole request | 20 s / 120 s |
Errors. Ingest and read routes answer {"error": "…", "status": N}. OTLP routes answer in google.rpc.Status form, {"code": N, "message": "…"} with the gRPC code (16 unauthenticated, 8 resource exhausted, 3 invalid argument), or the protobuf encoding of the same when the request was protobuf.
A 429 or 503 carries Retry-After in seconds; clients should wait and resend. Ingest is idempotent on event ids, so a resend never duplicates.
Running the examples. Every example below ran against a server started like this, with a fresh CODING_SH_HOME:
INGEST_KEY=$(coding.sh serve keygen --project team)
READ_KEY=$(coding.sh serve keygen --read --project '*')
SUPPORT_READ_KEY=$(coding.sh serve keygen --read --project support)
CODING_SH_INGEST_KEYS="team:$INGEST_KEY" \
CODING_SH_READ_KEYS="*:$READ_KEY,support:$SUPPORT_READ_KEY" \
coding.sh serve --port 4318 &
CSH=http://127.0.0.1:4318 The experiment examples use $EXP_A and $EXP_B: two ids from coding.sh exp list, run with the same CODING_SH_HOME as the server.
Routes
| Method | Path | Auth | Served by |
|---|---|---|---|
| POST | /v1/ingest | ingest key | serve |
| POST | /v1/traces | ingest key | serve, collector |
| POST | /v1/logs | ingest key | serve, collector |
| POST | /v1/metrics | ingest key | serve, collector |
| POST | /proxy/{path} | ingest key in x-coding-sh-key | serve |
| GET | /healthz | none | serve |
| GET | /readyz | none | serve |
| GET | /metrics | * read key or basic auth | serve |
| GET | / | read key or basic auth | serve, ui |
| GET | /index.html | read key or basic auth | serve, ui |
| GET | /api/projects | read key or basic auth | serve, ui |
| GET | /api/sessions | read key or basic auth | serve, ui |
| GET | /api/sessions/{id} | read key or basic auth | serve, ui |
| GET | /api/sessions/{id}/events | read key or basic auth | serve, ui |
| GET | /api/sessions/{id}/outline | read key or basic auth | serve, ui |
| GET | /api/sessions/{id}/rows | read key or basic auth | serve, ui |
| GET | /api/sessions/{id}/tree | read key or basic auth | serve, ui |
| GET | /api/sessions/{id}/replay | read key or basic auth | serve, ui |
| GET | /api/sessions/{id}/fork | ui token | ui |
| POST | /api/sessions/{id}/fork | ui token | ui |
| GET | /api/playground | ui token | ui |
| GET | /api/playground/calls/{id} | ui token | ui |
| POST | /api/playground/run | ui token | ui |
| POST | /api/playground/save | ui token | ui |
| GET | /api/blobs/{sha256} | read key or basic auth | serve, ui |
| GET | /api/calls/{id} | read key or basic auth | serve, ui |
| GET | /api/calls/{id}/diff | read key or basic auth | serve, ui |
| GET | /api/search | read key or basic auth | serve, ui |
| GET | /api/stats | read key or basic auth | serve, ui |
| GET | /api/metrics | read key or basic auth | serve, ui |
| GET | /api/facets | read key or basic auth | serve, ui |
| GET | /api/events | read key or basic auth | serve, ui |
| GET | /api/events/{id} | read key or basic auth | serve, ui |
| GET | /api/where | read key or basic auth | serve, ui |
| GET | /api/labels | read key or basic auth | serve, ui |
| POST | /api/labels | read key or basic auth | serve, ui |
| GET | /api/scores | read key or basic auth | serve, ui |
| POST | /api/scores | read key or basic auth | serve, ui |
| GET | /api/score-configs | read key or basic auth | serve, ui |
| POST | /api/score-configs | read key or basic auth | serve, ui |
| GET | /api/experiments | read key or basic auth | serve, ui |
| GET | /api/experiments/{id} | read key or basic auth | serve, ui |
| GET | /api/compare | read key or basic auth | serve, ui |
| GET | /api/benchmarks | read key or basic auth | serve, ui |
| GET | /api/benchmarks/{name} | read key or basic auth | serve, ui |
| GET | /api/workflows | read key or basic auth | serve, ui |
| GET | /api/workflows/{id} | read key or basic auth | serve, ui |
| GET | /api/tasks | read key or basic auth | serve, ui |
| GET | /api/runs | read key or basic auth | serve, ui |
| GET | /api/bundles | read key or basic auth | serve, ui |
| GET | /api/update | read key or basic auth | serve, ui |
| GET | /api/ingest | read key or basic auth | serve, ui |
Any other path answers 404 {"error":"not found","status":404}.
Which process serves what
coding.sh serve answers every route above. Two local commands answer a subset on their own port, and a viewer replica leaves out ingest:
| Process | Serves | Auth and limits |
|---|---|---|
coding.sh ui (default port 4319) | /, /index.html and every /api/* route; its /api/update really checks for a new release | A random token made per process: open the printed /?t=… URL once, or send Authorization: Bearer <token>. Read keys and basic auth do not apply. Binds loopback and checks the Host header |
coding.sh collector (default port 4318) | Only POST /v1/traces, /v1/logs and /v1/metrics | No auth. Binds loopback, rejects a non-loopback Host header, allows browser origins on localhost |
coding.sh serve --role viewer | Everything but /v1/* and /proxy/* | Same read auth as serve; a read replica that takes no ingest |
coding.sh ui has no /v1/*, /healthz, /readyz or /metrics. The collector answers any other path, /healthz and /v1/ingest included, with 404 {"code":5,"message":"no route for /healthz; use /v1/traces, /v1/logs or /v1/metrics"}. A viewer replica answers /v1/* and /proxy/* with 404 {"error":"this replica runs --role viewer and takes no ingest","status":404}.
Ingest
POST /v1/ingest
Native batched ingest, used by the SDKs. Auth: ingest key. Body: {"v": 0, "service": "…", "events": [AgentEvent, …]}.
Each event needs these fields:
| Field | Rule |
|---|---|
v | 0 |
id | 1-128 printable ASCII characters, unique per project. A resend with the same id inserts nothing |
sessionId | 1-200 characters, no control characters |
kind | One of the kinds below |
ts | Epoch milliseconds, no later than now plus one day |
payload | An object |
Optional fields: parentId, turnId, subagentId, agent, agentVersion, model, runId, traceId, spanId, parentSpanId, cwd, source, durationMs, usage (inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUsd, all non-negative) and repo. Anything else, and blobs, is dropped.
Kinds: session.start, session.end, user.message, assistant.message, llm.call, tool.start, tool.end, context.compact, turn.end, subagent.start, subagent.end, file.change, test.result, notification, span, error.
The server sets project from the key, source to "sdk" when absent, and agent to the kebab-cased service (or "sdk") when absent. An invalid event is rejected on its own and the rest are kept.
curl -sS -X POST "$CSH/v1/ingest" \
-H "Authorization: Bearer $INGEST_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"v": 0,
"service": "support-bot",
"events": [
{ "v": 0, "id": "evt-1", "sessionId": "chat-1", "ts": 1793872800000, "kind": "user.message",
"payload": { "text": "Where is order 1042?" } },
{ "v": 0, "id": "evt-2", "sessionId": "chat-1", "ts": 1793872801000, "kind": "llm.call",
"model": "gpt-4o", "durationMs": 900, "usage": { "inputTokens": 820, "outputTokens": 46 },
"payload": { "ok": true } },
{ "v": 0, "id": "evt-3", "sessionId": "chat-1", "ts": 1793872802000, "kind": "tool.start",
"payload": { "tool": "lookup_order", "toolUseId": "t1", "input": { "id": 1042 } } },
{ "v": 0, "id": "evt-4", "sessionId": "chat-1", "ts": 1793872802400, "kind": "tool.end",
"durationMs": 400, "payload": { "tool": "lookup_order", "toolUseId": "t1", "ok": true, "output": "shipped" } },
{ "v": 0, "id": "evt-5", "sessionId": "chat-1", "ts": 1793872803000, "kind": "assistant.message",
"payload": { "text": "Order 1042 shipped yesterday." } },
{ "v": 0, "id": "evt-6", "sessionId": "chat-1", "ts": -1, "kind": "session.end", "payload": {} }
]
}
EOF {"accepted":5,"inserted":5,"rejected":[{"index":5,"error":"ts must be epoch milliseconds between 0 and now + 1 day"}]} A 200 means the events are committed. accepted counts valid events, inserted the ones that were new (sending the same batch again gives "inserted":0), and rejected lists invalid events by index.
Browsers: a request with an Origin header is refused (403) unless that origin is listed in serve.cors_origins. An allowed origin gets Access-Control-Allow-Origin on every answer, errors included, and OPTIONS answers the preflight with 204 (methods POST, OPTIONS; headers Authorization, Content-Type, Content-Encoding). Requests without Origin (server-side SDKs, curl) are unaffected.
| Status | When | Body |
|---|---|---|
| 200 | committed | {"accepted":n,"inserted":k,"rejected":[…]} |
| 400 | body is not JSON, not an object, v is not 0, events is missing, or more than 10,000 events | {"error":"events must be an array","status":400} |
| 401 | missing or wrong key, or a read key; sets WWW-Authenticate: Bearer | {"error":"missing or invalid ingest key; send Authorization: Bearer <key>","status":401} |
| 404 | the server runs --role viewer | {"error":"this replica runs --role viewer and takes no ingest","status":404} |
| 403 | Origin not in serve.cors_origins | {"error":"origin https://app.example.com is not allowed","status":403} |
| 405 | method is not POST or OPTIONS; sets Allow: POST, OPTIONS | {"error":"use POST","status":405} |
| 413 | body over 16 MiB | {"error":"request body exceeds 16777216 bytes","status":413} |
| 415 | Content-Type is not application/json, or an unknown Content-Encoding | {"error":"Content-Type must be application/json","status":415} |
| 429 | ingest queue full; Retry-After set | {"error":"ingest queue is full; retry after 1s","status":429} |
| 503 | store error, shutting down, or a standby replica; Retry-After set | {"error":"server is shutting down; retry","status":503} |
POST /v1/traces, POST /v1/logs, POST /v1/metrics
OTLP/HTTP. Auth: ingest key. Point an OpenTelemetry exporter at the base URL and it appends the path:
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20csh_ik_… Bodies are application/json or application/x-protobuf, optionally compressed. Traces and logs become events and sessions: one session per trace, or per conversation when spans carry gen_ai.conversation.id or session.id. Metrics are accepted and dropped. Event ids come from traceId and spanId, so re-exports insert nothing. OPTIONS answers CORS preflight; browser origins are refused unless listed in serve.cors_origins.
curl -sS -X POST "$CSH/v1/traces" \
-H "Authorization: Bearer $INGEST_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"resourceSpans": [{
"resource": { "attributes": [{ "key": "service.name", "value": { "stringValue": "triage-agent" } }] },
"scopeSpans": [{
"scope": { "name": "manual" },
"spans": [{
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"spanId": "00f067aa0ba902b7",
"name": "chat gpt-4o",
"kind": 3,
"startTimeUnixNano": "1793880000000000000",
"endTimeUnixNano": "1793880001200000000",
"attributes": [
{ "key": "gen_ai.operation.name", "value": { "stringValue": "chat" } },
{ "key": "gen_ai.request.model", "value": { "stringValue": "gpt-4o" } },
{ "key": "gen_ai.conversation.id", "value": { "stringValue": "triage-12" } },
{ "key": "gen_ai.usage.input_tokens", "value": { "intValue": "410" } },
{ "key": "gen_ai.usage.output_tokens", "value": { "intValue": "38" } }
]
}]
}]
}]
}
EOF {} That span becomes session team/triage-12. When some spans or log records cannot be stored, the rest are kept and the 200 body says so (rejectedLogRecords for logs):
{"partialSuccess":{"rejectedSpans":"1","errorMessage":"span \"broken\" has no valid traceId/spanId"}} | Status | When | Body (JSON requests) |
|---|---|---|
| 200 | committed | {} or {"partialSuccess":{…}} |
| 204 | OPTIONS preflight with no Origin, or an allowed one | empty |
| 400 | body does not decode | {"code":3,"message":"invalid JSON: …"} |
| 401 | missing or wrong key; sets WWW-Authenticate: Bearer | {"code":16,"message":"missing or invalid ingest key; send Authorization: Bearer <key>"} |
| 403 | Origin not in serve.cors_origins | {"code":7,"message":"origin https://app.example.com is not allowed"} |
| 404 | the server runs --role viewer | {"error":"this replica runs --role viewer and takes no ingest","status":404} |
| 405 | method is not POST or OPTIONS; sets Allow | {"code":12,"message":"method GET not allowed"} |
| 413 | body over 16 MiB | {"code":8,"message":"request body exceeds 16777216 bytes"} |
| 415 | other Content-Type, or an unknown Content-Encoding | {"code":3,"message":"unsupported Content-Type \"text/plain\"; use application/x-protobuf or application/json"} |
| 429 | ingest queue full; Retry-After set | {"code":8,"message":"ingest queue is full; retry after 1s"} |
| 503 | store error, shutting down, or a standby replica; Retry-After set | {"code":14,"message":"server is shutting down; retry"} |
Model API proxy
POST /proxy/{path}
The model API proxy of coding.sh proxy, mounted so a team needs one URL. /proxy/v1/messages and /proxy/v1/messages/count_tokens go to the Anthropic API, /proxy/v1/chat/completions and /proxy/v1/responses to the OpenAI API; any other path and method is forwarded without being logged. Upstreams come from [proxy] in the server's config.toml.
The ingest key goes in x-coding-sh-key and picks the project the call is logged into. It is dropped before forwarding. Authorization and x-api-key carry the client's own API key, which goes upstream unchanged and is never stored. Streams pass through as they arrive.
curl -sS "$CSH/proxy/v1/messages" -H "x-coding-sh-key: $INGEST_KEY" \
-H "x-api-key: $ANTHROPIC_API_KEY" -H "anthropic-version: 2023-06-01" -H "content-type: application/json" \
-d '{"model": "claude-haiku-4-5", "max_tokens": 64, "messages": [{"role": "user", "content": "hi"}]}' The upstream's status, headers and body come back as they are. The proxy's own errors use the Anthropic error shape, so agents show the message:
| Status | When | Body |
|---|---|---|
| 401 | missing or wrong ingest key | {"type":"error","error":{"type":"authentication_error","message":"coding.sh serve: missing or invalid ingest key; send it in the x-coding-sh-key header"}} |
| 404 | the server runs --role viewer | {"error":"this replica runs --role viewer and takes no ingest","status":404} |
| 403 | a browser request (Origin other than localhost) | {"type":"error","error":{"type":"invalid_request_error","message":"coding.sh proxy: cross-origin requests are not allowed"}} |
| 502 | the upstream could not be reached | {"type":"error","error":{"type":"api_error","message":"coding.sh proxy: upstream https://api.anthropic.com unreachable (…)"}} |
Each call becomes an llm.call event with source: "proxy", read back with GET /api/calls/{id}.
Health and metrics
/healthz, /readyz and /metrics answer GET and HEAD. Any other method gets 405 {"error":"method not allowed","status":405} with Allow: GET, HEAD.
GET /healthz
Auth: none. 200 while the process is up. It never touches the store.
curl -sS "$CSH/healthz" {"ok":true} GET /readyz
Auth: none. 200 when the store answers within 2 s, this replica holds the writer lock (or runs --role viewer), the queue is under 90% and the server is not shutting down. Otherwise 503, with reason set to store did not answer, shutting down, standby: another replica holds the writer lock or ingest queue above 90%. The body holds no secrets.
curl -sS "$CSH/readyz" {"ok":true,"store":"sqlite","writer":true,"role":"all","queue":{"events":0,"max":50000},"insecure":false} A --role viewer replica answers {"ok":true,"store":"sqlite","writer":false,"role":"viewer",…}.
GET /metrics
Auth: a * read key or basic auth (or nothing, on a loopback bind with no keys). The counters cover every project and their labels name them, so a project-scoped read key gets 403 {"error":"metrics need a read key for every project (\"*\") or basic auth","status":403}. Prometheus text format (text/plain; version=0.0.4). The metric names are listed in PRODUCTION.md.
curl -sS "$CSH/metrics" -H "Authorization: Bearer $READ_KEY" # HELP coding_sh_http_requests_total HTTP requests by route and status.
# TYPE coding_sh_http_requests_total counter
coding_sh_http_requests_total{route="/v1/ingest",status="200"} 4
coding_sh_http_requests_total{route="/v1/traces",status="200"} 2
# HELP coding_sh_ingest_events_total Ingested events by project and result.
# TYPE coding_sh_ingest_events_total counter
coding_sh_ingest_events_total{project="team",result="inserted"} 26
coding_sh_ingest_events_total{project="team",result="duplicate"} 17
coding_sh_ingest_events_total{project="team",result="rejected"} 2
coding_sh_queue_events 0
coding_sh_writer_lock_held 1 Without a valid read credential: 401 {"error":"missing or invalid read key","status":401} with WWW-Authenticate: Bearer (or Basic realm="coding.sh" when basic auth is configured).
Viewer
GET /, GET /index.html
The viewer page (HTML, also HEAD). Auth: read key or basic auth. /?t=<read key> trades the key for an HttpOnly; SameSite=Strict cookie named csh_rk and redirects to /, so the key leaves the address bar. Add CODING_SH_TRUST_PROXY=1 behind a TLS proxy to mark the cookie Secure.
curl -sS -i "$CSH/?t=$READ_KEY" HTTP/1.1 302 Found
Set-Cookie: csh_rk=csh_rk_…; HttpOnly; SameSite=Strict; Path=/
Location: / A missing or wrong key gets a 401 HTML page that explains how to sign in, with WWW-Authenticate: Basic when basic auth is configured. Other methods get 405.
The page arrives with the first sessions page drawn as rows, so the list shows before its script runs, and without JavaScript.
It also embeds the responses the page asks for first in a <script type="application/json" id="boot-data"> element: /api/sessions, /api/stats, /api/facets and /api/where with their default parameters, and the first row's session when it has 400 events or fewer.