---
title: "REST API reference · coding.sh docs"
description: "Every route coding.sh serve answers, with auth and examples: ingest, OTLP, the model API proxy, health and metrics."
canonical: https://coding.sh/docs/api
human_url: https://coding.sh/docs/api
markdown_url: https://coding.sh/docs/api.md
agents_url: https://coding.sh/agents.md
llms_url: https://coding.sh/llms.txt
updated: 2026-11-11
---

# 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](https://github.com/jackulau/coding.sh/blob/main/docs/PRODUCTION.md). Server settings are in [config.md](https://coding.sh/docs/config#serve), and a full team setup is in [self-host-team.md](https://coding.sh/docs/how-to/self-host-team).

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

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

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

| 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`](https://coding.sh/docs/api/read#get-apiprojects) | read key or basic auth | serve, ui |
| GET | [`/api/sessions`](https://coding.sh/docs/api/read#get-apisessions) | read key or basic auth | serve, ui |
| GET | [`/api/sessions/{id}`](https://coding.sh/docs/api/read#get-apisessionsid) | read key or basic auth | serve, ui |
| GET | [`/api/sessions/{id}/events`](https://coding.sh/docs/api/read#get-apisessionsidevents) | read key or basic auth | serve, ui |
| GET | [`/api/sessions/{id}/outline`](https://coding.sh/docs/api/read#get-apisessionsidoutline) | read key or basic auth | serve, ui |
| GET | [`/api/sessions/{id}/rows`](https://coding.sh/docs/api/read#get-apisessionsidrows) | read key or basic auth | serve, ui |
| GET | [`/api/sessions/{id}/tree`](https://coding.sh/docs/api/read#get-apisessionsidtree) | read key or basic auth | serve, ui |
| GET | [`/api/sessions/{id}/replay`](https://coding.sh/docs/api/read#get-apisessionsidreplay) | read key or basic auth | serve, ui |
| GET | [`/api/sessions/{id}/fork`](https://coding.sh/docs/api/read#get-apisessionsidfork-post-apisessionsidfork) | ui token | ui |
| POST | [`/api/sessions/{id}/fork`](https://coding.sh/docs/api/read#get-apisessionsidfork-post-apisessionsidfork) | ui token | ui |
| GET | [`/api/playground`](https://coding.sh/docs/api/read#get-apiplayground-get-apiplaygroundcallsid-post-apiplaygroundrun-post-apiplaygroundsave) | ui token | ui |
| GET | [`/api/playground/calls/{id}`](https://coding.sh/docs/api/read#get-apiplayground-get-apiplaygroundcallsid-post-apiplaygroundrun-post-apiplaygroundsave) | ui token | ui |
| POST | [`/api/playground/run`](https://coding.sh/docs/api/read#get-apiplayground-get-apiplaygroundcallsid-post-apiplaygroundrun-post-apiplaygroundsave) | ui token | ui |
| POST | [`/api/playground/save`](https://coding.sh/docs/api/read#get-apiplayground-get-apiplaygroundcallsid-post-apiplaygroundrun-post-apiplaygroundsave) | ui token | ui |
| GET | [`/api/blobs/{sha256}`](https://coding.sh/docs/api/read#get-apiblobssha256) | read key or basic auth | serve, ui |
| GET | [`/api/calls/{id}`](https://coding.sh/docs/api/read#get-apicallsid) | read key or basic auth | serve, ui |
| GET | [`/api/calls/{id}/diff`](https://coding.sh/docs/api/read#get-apicallsiddiff) | read key or basic auth | serve, ui |
| GET | [`/api/search`](https://coding.sh/docs/api/read/search#get-apisearch) | read key or basic auth | serve, ui |
| GET | [`/api/stats`](https://coding.sh/docs/api/read/search#get-apistats) | read key or basic auth | serve, ui |
| GET | [`/api/metrics`](https://coding.sh/docs/api/read/search#get-apimetrics) | read key or basic auth | serve, ui |
| GET | [`/api/facets`](https://coding.sh/docs/api/read/search#get-apifacets) | read key or basic auth | serve, ui |
| GET | [`/api/events`](https://coding.sh/docs/api/read/search#get-apievents) | read key or basic auth | serve, ui |
| GET | [`/api/events/{id}`](https://coding.sh/docs/api/read/search#get-apieventsid) | read key or basic auth | serve, ui |
| GET | [`/api/where`](https://coding.sh/docs/api/read/search#get-apiwhere) | read key or basic auth | serve, ui |
| GET | [`/api/labels`](https://coding.sh/docs/api/read/search#get-apilabels) | read key or basic auth | serve, ui |
| POST | [`/api/labels`](https://coding.sh/docs/api/read/search#post-apilabels) | read key or basic auth | serve, ui |
| GET | [`/api/scores`](https://coding.sh/docs/api/read/search#get-apiscores) | read key or basic auth | serve, ui |
| POST | [`/api/scores`](https://coding.sh/docs/api/read/search#post-apiscores) | read key or basic auth | serve, ui |
| GET | [`/api/score-configs`](https://coding.sh/docs/api/read/search#get-apiscore-configs-post-apiscore-configs) | read key or basic auth | serve, ui |
| POST | [`/api/score-configs`](https://coding.sh/docs/api/read/search#get-apiscore-configs-post-apiscore-configs) | read key or basic auth | serve, ui |
| GET | [`/api/experiments`](https://coding.sh/docs/api/read/search#get-apiexperiments) | read key or basic auth | serve, ui |
| GET | [`/api/experiments/{id}`](https://coding.sh/docs/api/read/search#get-apiexperimentsid) | read key or basic auth | serve, ui |
| GET | [`/api/compare`](https://coding.sh/docs/api/read/search#get-apicompare) | read key or basic auth | serve, ui |
| GET | [`/api/benchmarks`](https://coding.sh/docs/api/read/search#get-apibenchmarks) | read key or basic auth | serve, ui |
| GET | [`/api/benchmarks/{name}`](https://coding.sh/docs/api/read/search#get-apibenchmarksname) | read key or basic auth | serve, ui |
| GET | [`/api/workflows`](https://coding.sh/docs/api/read/search#get-apiworkflows) | read key or basic auth | serve, ui |
| GET | [`/api/workflows/{id}`](https://coding.sh/docs/api/read/search#get-apiworkflowsid) | read key or basic auth | serve, ui |
| GET | [`/api/tasks`](https://coding.sh/docs/api/read/search#get-apitasks-get-apiruns-get-apibundles) | read key or basic auth | serve, ui |
| GET | [`/api/runs`](https://coding.sh/docs/api/read/search#get-apitasks-get-apiruns-get-apibundles) | read key or basic auth | serve, ui |
| GET | [`/api/bundles`](https://coding.sh/docs/api/read/search#get-apitasks-get-apiruns-get-apibundles) | read key or basic auth | serve, ui |
| GET | [`/api/update`](https://coding.sh/docs/api/read/search#get-apiupdate) | read key or basic auth | serve, ui |
| GET | [`/api/ingest`](https://coding.sh/docs/api/read/search#get-apiingest) | 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.

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.

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

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"}}
```

| 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`](https://coding.sh/docs/how-to/capture-with-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:

| 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}`](https://coding.sh/docs/api/read#get-apicallsid).

## 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](https://github.com/jackulau/coding.sh/blob/main/docs/PRODUCTION.md#17-metrics-and-logs).

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.

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