Docs REST API

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.

CredentialHow to send itOpens
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 authAuthorization: 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.json and 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-1 sent with a team key becomes team/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-1 answers 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.

LimitValue
Ingest body, before and after decompression16 MiB (16777216 bytes)
Events per /v1/ingest request10,000
Ingest queue50,000 events or 128 MiB (CODING_SH_QUEUE_MAX_EVENTS, CODING_SH_QUEUE_MAX_BYTES)
POST /api/labels, /api/scores, /api/score-configs body1 MB
Blob response2 MiB unless ?full=1
Request headers / whole request20 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:

shell
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

MethodPathAuthServed by
POSTingest keyserve
POSTingest keyserve, collector
POSTingest keyserve, collector
POSTingest keyserve, collector
POSTingest key in x-coding-sh-keyserve
GETnoneserve
GETnoneserve
GET* read key or basic authserve
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETui tokenui
POSTui tokenui
GETui tokenui
GETui tokenui
POSTui tokenui
POSTui tokenui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
POSTread key or basic authserve, ui
GETread key or basic authserve, ui
POSTread key or basic authserve, ui
GETread key or basic authserve, ui
POSTread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, ui
GETread key or basic authserve, 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:

ProcessServesAuth and limits
coding.sh ui (default port 4319)/, /index.html and every /api/* route; its /api/update really checks for a new releaseA 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/metricsNo auth. Binds loopback, rejects a non-loopback Host header, allows browser origins on localhost
coding.sh serve --role viewerEverything 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:

FieldRule
v0
id1-128 printable ASCII characters, unique per project. A resend with the same id inserts nothing
sessionId1-200 characters, no control characters
kindOne of the kinds below
tsEpoch milliseconds, no later than now plus one day
payloadAn 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.

shell
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
json
{"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.

StatusWhenBody
200committed{"accepted":n,"inserted":k,"rejected":[…]}
400body 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}
401missing or wrong key, or a read key; sets WWW-Authenticate: Bearer{"error":"missing or invalid ingest key; send Authorization: Bearer <key>","status":401}
404the server runs --role viewer{"error":"this replica runs --role viewer and takes no ingest","status":404}
403Origin not in serve.cors_origins{"error":"origin https://app.example.com is not allowed","status":403}
405method is not POST or OPTIONS; sets Allow: POST, OPTIONS{"error":"use POST","status":405}
413body over 16 MiB{"error":"request body exceeds 16777216 bytes","status":413}
415Content-Type is not application/json, or an unknown Content-Encoding{"error":"Content-Type must be application/json","status":415}
429ingest queue full; Retry-After set{"error":"ingest queue is full; retry after 1s","status":429}
503store 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:

output
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.

shell
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
json
{}

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):

json
{"partialSuccess":{"rejectedSpans":"1","errorMessage":"span \"broken\" has no valid traceId/spanId"}}
StatusWhenBody (JSON requests)
200committed{} or {"partialSuccess":{…}}
204OPTIONS preflight with no Origin, or an allowed oneempty
400body does not decode{"code":3,"message":"invalid JSON: …"}
401missing or wrong key; sets WWW-Authenticate: Bearer{"code":16,"message":"missing or invalid ingest key; send Authorization: Bearer <key>"}
403Origin not in serve.cors_origins{"code":7,"message":"origin https://app.example.com is not allowed"}
404the server runs --role viewer{"error":"this replica runs --role viewer and takes no ingest","status":404}
405method is not POST or OPTIONS; sets Allow{"code":12,"message":"method GET not allowed"}
413body over 16 MiB{"code":8,"message":"request body exceeds 16777216 bytes"}
415other Content-Type, or an unknown Content-Encoding{"code":3,"message":"unsupported Content-Type \"text/plain\"; use application/x-protobuf or application/json"}
429ingest queue full; Retry-After set{"code":8,"message":"ingest queue is full; retry after 1s"}
503store 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.

shell
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:

StatusWhenBody
401missing 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"}}
404the server runs --role viewer{"error":"this replica runs --role viewer and takes no ingest","status":404}
403a browser request (Origin other than localhost){"type":"error","error":{"type":"invalid_request_error","message":"coding.sh proxy: cross-origin requests are not allowed"}}
502the 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.

shell
curl -sS "$CSH/healthz"
json
{"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.

shell
curl -sS "$CSH/readyz"
json
{"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.

shell
curl -sS "$CSH/metrics" -H "Authorization: Bearer $READ_KEY"
output
# 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.

shell
curl -sS -i "$CSH/?t=$READ_KEY"
output
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.