How to read this page. The read API and the realtime SSE feed are built and running; the sections below describe what
apps/server/srcactually serves. The page was first written before any code existed, as a design target derived from the design basis (docs/ai/DESIGN.md) and the build plan (docs/analysis/development-plan.md), so it still carries that older narrative underneath — every design-era table is kept as the record, with anAs builtnote wherever the shipped system settled a question the design left open. Markings such as (planned) and (leaning — unconfirmed) belong to that record and no longer describe the current system. The security invariants were binding then and remain binding now.
Update — 2026-07 (as built). The read API is built. This page was written before any code existed, so the “no application code is built yet” framing above and every (planned shape — exact path undecided) marking below are out of date. What actually ships in
apps/server/src:
- Twelve routes, all under
/api/, all behind the one auth gate (re-counted 2026-09-19). ElevenGET(/api/health,/api/stream,/api/sessions,/api/sessions/:id,/api/sessions/:id/tree,/api/sessions/:id/events,/api/sessions/:id/cost-analysis,/api/cost/summary,/api/cost/delegation-savings,/api/dag/global,/api/changes) and onePOST(/api/hooks/event, the hook liveness receiver). Note the/apiprefix — the design-era table below writes the tree route asGET /sessions/:id/tree; the real path is/api/sessions/:id/tree.- The gate is registered before any route and authorizes on the routed path (
request.routeOptions.url), not the raw URL, because the router percent-decodes and a raw-prefix check would let/%61pi/healththrough. Loopback bind, timing-safe token compare and the stream’s same-origin check are all as designed and binding.- Every route carries TypeBox response schemas with
additionalProperties: false, a uniform{ "error": "…" }shape on every non-2xx, and capped limit/offset pagination on everything unbounded (limit≤ 200,offset≤ 1000000,topN≤ 50, DAGlimit≤ 5000).- There is no write surface beyond the hook receiver. The alerts CRUD described in Operator alerts API below was cut — see the note in that section.
- The stream carries three typed event types, not generic projection deltas:
session-ingested,agent-status-changedandingest-failed(the third documented 2026-08, when the SPA gained a listener for it — a quarantined session is now a visible banner, not a silent absence). Resumability is not implemented as replay — see the “Resumability” note in the realtime-feed section below.The design-era prose and tables are kept below as the record, with
As builtnotes where the shipped system settled a question the page left open.
This page covers the two things a client of agenthropic ever talks to: a set of
authenticated HTTP read endpoints over the projected SQLite state, and one
authenticated realtime push endpoint, /api/stream, that fans out projection
deltas over server-sent events. The key takeaway: every endpoint — read, write, and the
stream — sits behind the same mandatory, timing-safe DASHBOARD_TOKEN gate and the same
loopback-only bind; the realtime transport is SSE, not WebSocket (CD-5); and nothing
this API returns is inferred — every token is copied verbatim from
~/.claude/projects/*.jsonl, every dollar is that ground-truth token count times a
dated model_pricing row, and the subagent tree is served by a query over the
persisted orchestration_edges table, never reconstructed from raw events at request
time.
Three rules apply uniformly to every route below, read or write, and are restated here rather than per-endpoint because there is no exception anywhere in the design:
apps/server, WP-U0) binds 127.0.0.1
exclusively — a loopback-or-fail listen call, never 0.0.0.0, not even behind a
flag. See security model, rule 1.timingSafeEqual token, on every route. WP-U2 (Read API foundation)
states its Done-when as “every read route auth-guarded (timing-safe)” — deliberately
stricter than DESIGN §8’s original “no unauthenticated write endpoints” wording,
closing the exact gap cast’s unauthenticated GETs left open. WP-A8 (operator
alerts CRUD) carries the identical requirement for the write surface: “all write
endpoints token-guarded, cross-origin rejected.” There is no unauthenticated route in
this design, full stop — see security model, rules 2 and 5.
The server refuses to start at all if DASHBOARD_TOKEN is unset; it never falls back
to “no auth needed.”docs/analysis/concept-analysis-v2.md settles the realtime transport as SSE
(“server→browser-only feed; revisit WebSocket only if bidirectional control is ever
needed — it is not”). DESIGN §8’s older wording still says “WebSocket”; that wording
is superseded, and this page — like the security model
— follows the later, canonical decision. A cross-origin Origin header on the stream
is rejected outright; the server never emits a wildcard Access-Control-Allow-Origin. every request, read or write, passes through:
client ──▶ [ same-origin check ] ──▶ [ timingSafeEqual(DASHBOARD_TOKEN) ] ──▶ route
(stream only) (all routes, no exceptions)
│
┌────────────────────────────┼───────────────────────────┐
▼ ▼ ▼
read endpoints /api/stream (SSE) alerts CRUD
(WP-U2 … U4) (WP-U1) (WP-A8, write)
(As built: the diagram’s third branch never happened — the alerts CRUD surface was cut.
The only write route is POST /api/hooks/event, which crosses the identical gate.)
There is no route in this design — present or planned — that is reachable without
crossing both checks that apply to it. See the security model
for the full nine-rule catalogue this API sits inside, and
configuration for how DASHBOARD_TOKEN itself is supplied to the
running server.
GET /api/health/api/health is auth-gated like everything else — there is no unauthenticated probe
path — and it answers 200 with a payload whose schema declares two required fields and
six optional ones:
| Field | Type | Always present? | Meaning |
|---|---|---|---|
status |
the literal "ok" |
yes | The process is serving. It stays "ok" even while ingest is still replaying: a warming server is healthy, just not yet current. |
schemaVersion |
integer | yes | The migration version the open database is at. |
ingestSkips |
object of { reason: count } |
no | Cumulative count of skip events per skip reason since boot; a file declined again on a later pass counts again, so it is not a count of distinct files. |
ingest |
"replaying" or "idle" |
no | "replaying" between the loopback bind and the end of the startup replay pass; "idle" after, whether or not that pass could read the corpus. |
lastTickDurationMs |
number | no | Wall-clock duration of the last completed corpus pass. |
crossSessionUsageCollisions |
integer | no | Usage messages skipped since boot because another session had already claimed them. |
sessionsExcluded |
integer | no | Sessions whose latest ingest attempt failed — each is either absent from every stored total or present in it only at an older extent than the corpus now holds (a session quarantined after a clean ingest keeps its last good pass), so dollar totals are a lower bound while this is non-zero. status stays "ok": the server is surviving correctly, it is just not complete. (Amended 2026-09-25 (OO): this used to say “counted nowhere”.) |
sessionsQuarantined |
integer | no | The subset of excluded sessions that will not be retried until the session’s bytes or the pricing table change — the ones that need a human, typically a missing price. |
An absent field is never a zero, and that distinction is the point of the endpoint.
Each optional field is backed by a seam the composition root wires in only when ingest is
running. When the seam is absent — a server built without ingest wiring, or one booted
with DASHBOARD_INGEST=0 — the field is omitted. When the seam is present but has nothing to report yet (no corpus pass has
finished), the field is also omitted, because a lastTickDurationMs of 0 would read
as “the poll is instantaneous”, which is the wrong fact rather than a missing one. The
handler’s own comment states the rule: “No pass yet” and “no seam” both OMIT the field —
never a fake number. crossSessionUsageCollisions is the one case where a present seam
does report a genuine 0: zero collisions is a measured result, not an absence.
Why skips are on the health payload at all. A skipped corpus file freezes that session’s dollar totals — the transcript that would have advanced them was never read. That consequence is invisible in the dashboard views, so the running total is surfaced where an operator can see it without log access. Eight reasons exist, and they are the complete set:
| Skip reason | What it means |
|---|---|
oversize |
The file exceeded the per-file read cap and was deliberately not read. |
symlink |
The entry is a symlink; the walk never follows one out of the corpus root. |
not-regular-file |
Not a regular file (directory, socket, device). |
unreadable |
An I/O error (ENOENT, EACCES, …) on this file. |
empty-agent |
A subagent artifact with no usable records. |
empty-main |
A main transcript with no usable records. |
non-artifact |
A file under a session directory that is not a transcript artifact. |
duplicate-session |
The same session id was discovered under two project slugs. |
too-deep |
A real directory under <uuid>/subagents/** sat past the walk’s depth limit (ReadLimits.maxDepth, default 4, PROVISIONAL). The walk did not enter it, so nothing beneath it was read. |
AMENDED 2026-09-23 (J-6). Nine reasons exist, not eight. too-deep is the ninth and
is the last row of the table above. The count above was correct when written: the walk used to
return at the depth limit without recording anything, so artifacts beneath a too-deep directory
were dropped with no counter at all - precisely the invisible freeze this section says the
health payload exists to expose. The authority is the SkipReason union in
apps/server/src/corpus/fs-port.ts; any count in prose is a copy of it. One scope limit worth
stating: only the substrate walk (walkArtifacts,
apps/server/src/corpus/disk-substrate.ts) records too-deep. The change-detection walk in
apps/server/src/corpus/fingerprint.ts still stops silently at the same depth, because its
output is a fingerprint, not a skip list.
duplicate-session deserves its own note. When one session id appears under two slugs the
tiebreak keeps the lexicographically smallest slug — deterministic by construction, so
two runs over the same corpus agree, rather than mtime-based, which would make ingest
racy. The losing copy is counted here, not dropped silently: the number is how you
find out the corpus contains a duplicate at all. The rule itself is PROVISIONAL
(LABEL-ME) and awaits ratification.
/api/stream/api/stream is the one concrete path the sources name explicitly (WP-U1,
RealtimeHub SSE endpoint). Every other path in this page is a planned shape, not a
literal one — see the Read endpoints table below. (As built: every
path is now literal — see the table’s “As built” column.)
As built:
/api/streamis a hijacked Fastify reply that writes aretry: <ms>field, a: connectedcomment, then hub frames, with a: heartbeatcomment every 15 s. Heartbeats are SSE comment frames and therefore never surface toEventSource— client liveness is the connection state, not a heartbeat count. Three typed frames are emitted:session-ingested,agent-status-changedandingest-failed(update 2026-08: this page previously said two — the third frame was already published but undocumented, and the SPA did not listen for it). The frame-type list is a single shared constant (SERVER_EVENT_TYPESinpackages/shared) imported by both the server bridge and the SPA’s SSE client, becauseEventSourcesilently drops a named event with no registered listener. The token may be presented as?token=here (and only here) becauseEventSourcecannot set headers; the server’s request-log serializer redacts it. The same-origin check runs before the token check, so a foreignOrigingets 403 whether or not it holds a valid token.
| Property | Value | Source |
|---|---|---|
| Transport | Server-sent events (SSE) — one long-lived HTTP response, server→browser only | CD-5; security model rule 4 |
| Path | /api/stream (fixed) |
WP-U1 Done-when |
| Direction | Server → browser only; no client-to-server control channel over this connection | CD-5 (“revisit WebSocket only if bidirectional control is ever needed — it is not”) |
| Auth | Same mandatory timingSafeEqual(DASHBOARD_TOKEN) gate as every other route |
WP-U2, security model rule 2 |
| Origin check | Same-origin only; a cross-origin Origin header is rejected; no wildcard CORS |
WP-U1 Done-when: “a cross-origin Origin on /api/stream is rejected; no wildcard CORS” |
| Resumability | Resumable — the connection can pick back up after a drop | WP-U1: “server→browser, same-origin, auth-gated, resumable” |
| What it pushes | Deltas from the projection layer (new/changed sessions, agents, orchestration_edges, token_usage rows) |
docs/analysis/development-plan.md §7: “WP-U1 needs a projection change-notifier that only exists after WP-IN7” |
As built, the last two rows resolved differently:
| Property | As built |
|---|---|
| Resumability | Reconnect with bounded replay (since 2026-09-26; until then reconnect only). The server sends a retry: hint and the browser’s EventSource auto-reconnects, sending the last frame id it saw as Last-Event-ID; the hub keeps the last 256 frames and replays those after that id, in order, before live frames resume (and before the : connected comment). A header that is not a plain decimal id is ignored. Frames older than the window, and frames lost across a server restart (ids restart at 1), are still lost, and the dashboard reports them as a stream gap. The SPA compensates by treating any stream event as a cue to refetch persisted truth, so the displayed state re-converges — but a client that needs a gapless event log must read GET /api/sessions/:id/events, not the stream. |
| What it pushes | Three typed frames only — session-ingested (a session was persisted; refetch), agent-status-changed (one agent moved between status buckets, including into unknown via the missing-Stop watchdog), and ingest-failed (a session’s ingest failed; a typed arm of the closed shared union since 2026-09-09 — until then it rode a generic catch-all arm, now deleted — shaped { "type": "ingest-failed", "payload": { sessionId, reason, attempt, willRetry, occurredAt } } with a sanitized, single-line, path-free reason, attempt a 1-based integer and willRetry false once the session is quarantined. The payload envelope is the documented wire shape and is deliberately kept although the other two frames carry occurredAt at the top level: the SPA carries no schema library and narrows this frame by hand, so the bytes are the contract. It narrows the other two frames by hand as well; all three narrowings are pinned to the shared TypeScript types in apps/web/test/realtime-wire-shape.test.ts (since 2026-09-26 for agent-status-changed and session-ingested), so a server-side field change fails the web typecheck instead of failing silently in the browser. The SPA renders it as a dismissible banner on the live board, because a quarantined session never reaches the read API and would otherwise be invisible). The union is closed (D5): an unknown event: name is not a fourth kind the client should tolerate; the browser’s EventSource never delivers a named event with no registered listener, so such a frame is never rendered, and because every frame carries the hub’s id: sequence it still surfaces as a gap at the next frame that is heard, where the live board counts it — dropped and counted, never rendered. Not a generic row-delta feed over sessions/agents/orchestration_edges/token_usage. |
The event-push model. /api/stream is a fan-out off already-committed projection
state, not a raw firehose of events_raw — the same “single-writer pipeline, read-only
fan-out” shape the architecture overview describes for
every read path (API, realtime hub, webhook sink alike). Practically, this means a
client never has to reconcile “hook value or JSONL value” itself: by the time a delta
reaches /api/stream, the projection has already applied CD-2’s per-field precedence
rule once, at projection time — the stream only ever announces the settled result. The
build-plan notes name a sequencing detail worth carrying into any client implementation:
WP-U1 is built first against an in-memory change-notifier fake (Phase 1) and rewired
to the real projection emitter only once WP-IN7 (the projection) lands in Phase 3 —
so the wire shape of a pushed delta is fixed early, but the events it can actually
carry only become meaningful once Phase 3’s projection exists.
Same-origin rejection, concretely. A request to /api/stream whose Origin header
does not match the dashboard’s own origin is rejected before the connection is ever
promoted to a stream — this is the same shared/security origin-check primitive
WP-F7 builds and unit-tests, wired into the bootstrap by WP-U0, and it is what stops
an unrelated tab you merely have open from silently attaching to your live feed even if
it somehow obtained or guessed the token (security model rule 4). There is no
same-origin exemption for a valid token presented cross-origin — both checks apply.
Resumability, as a design constraint, not yet a mechanism. WP-U1’s Done-when names
“resumable” as a requirement but the sources do not fix how — e.g., a Last-Event-ID
replay against events_raw.seq (a readSince() the design sketch named for WP-IN2 — corrected
2026-09-26: never built; events_raw has no seq column and the port has only append and
readAll, see the data model) is a plausible shape given the schema, but no source states this as
the literal mechanism. Treat resumability as a fixed requirement and its exact protocol
as (planned).
Amended 2026-09-26 — resumability built. The note below was the as-built state until that date.
RealtimeHubnow keeps a bounded buffer (256 frames by default) and the stream route replays every buffered frame after the request’sLast-Event-IDbefore joining live fan-out, in one synchronous step so nothing is sent twice or skipped. Limits, stated: frames older than the buffer are gone; ids restart per process, so a reconnect across a restart can still miss frames undetected by id alone (the SPA’s refetch on reconnect repairs the state); there is still noevents_raw.seqcursor.As built (until 2026-09-26): the mechanism chosen was browser auto-reconnect, not replay. The server emits a
retry:hint and nothing else; noLast-Event-IDis read or honoured, and noevents_raw.seqcursor is exposed on the stream. That is a real gap againstWP-U1’s “resumable” wording and is recorded here rather than papered over: a client that drops the connection misses the frames sent in the interim. The SPA’s answer is to refetch from the read API on reconnect, which restores correct state but not the missed event sequence.
Every row below other than /api/stream (above) and GET /sessions/:id/tree is a
(planned shape — exact path undecided) — the sources fix the resource, the backing
data, and the owning work package, but not a literal REST path. Do not treat any path
other than those two as decided.
(The paths are all decided now. The design-era table is kept below; the “As built” column names the route that actually shipped.)
| Resource | Purpose | Backing data | Source WP | As built |
|---|---|---|---|---|
GET /sessions/:id/tree |
The session-scoped subagent tree (daily Q1/Q3/Q5) | Query over orchestration_edges, joined to agents |
WP-U3 — fixed path: “GET /sessions/:id/tree built from a query over orchestration_edges (proven, not reconstruction)” |
GET /api/sessions/:id/tree — note the /api prefix |
| Sessions & agents (planned shape) | List/get sessions and their agents, including per-agent status (working/waiting/completed/error) |
sessions, agents projection tables |
WP-U3 |
GET /api/sessions?limit&offset (returns {sessions,total,limit,offset}) and GET /api/sessions/:id. The status enumeration gained a fifth value, unknown — see below |
| Cost & delegation-savings (planned shape) | Per-session/agent dollar cost and the Haiku/Sonnet-routing delegation-savings figure (daily Q2/Q4) | token_usage × model_pricing, via CostEngine (WP-C3), delegation-savings via WP-C5 |
WP-U4 |
Split in three: GET /api/cost/summary?topN (DB rollup: totals, per-model, per-day, top sessions), GET /api/sessions/:id/cost-analysis?topTierModel (compaction-aware cost + delegation savings, computed from the JSONL substrate) and GET /api/cost/delegation-savings?topTierModel (the corpus-wide delegation-savings estimate, rebuilt from stored token_usage / agents rows) |
| Global orchestration DAG (planned shape) | The cross-session, per-instance persisted DAG (the moat view) | Query over orchestration_edges across sessions, keyed by instance/host_id |
WP-U4; see the DAG moat |
GET /api/dag/global?limit — returns nodes, edges and a counts block whose truncated flag the client must surface |
| Token usage (planned shape) | Fine-grained ground-truth token buckets (speed/inference_geo/service_tier), including PreCompact baselines |
token_usage |
WP-U3/WP-U4, backed by WP-D8 |
No dedicated endpoint. Token figures are served folded into the session, tree, DAG and cost responses (totalTokens, costUsd, unpricedTokens); there is no route that returns raw token_usage rows or per-bucket breakdowns |
| Events (planned shape) | Read access to normalized events (and, where exposed, events_raw) for a session/agent |
events, events_raw |
Implied by WP-U2’s Read API foundation over the projection; no dedicated WP names an events-listing endpoint explicitly |
GET /api/sessions/:id/events?limit&offset (WP-D5). Serves the normalized events table only — events_raw is never exposed |
Every route in this table — fixed or planned — is TypeBox-contract-validated and shares
one auth guard implementation: WP-U2 (Read API foundation) is explicitly “a Fastify
plugin, TypeBox contracts, auth guard, shared DTOs,” so no individual route author can
forget to wrap a new endpoint in the gate. (As built: this held — the gate is a single
onRequest hook on the whole app, and the route plugin is registered inside its scope,
so a new route is gated by construction rather than by remembering.)
GET /api/sessions/:id/events never conflates “no events” with “no session”. A
known session with zero hook events is 200 with an empty array; only an unknown
session id is 404. Rows are oldest-first with id as tiebreak and carry a total
so truncation stays visible.agents / orchestration_edges / token_usage, and their absence means
nothing about whether an agent ran — hooks are a best-effort secondary channel and
JSONL transcripts are ground truth. Only identifiers are projected into events,
never payload content.occurredAtSource: "receipt" — the DTO says so on
every row rather than letting a reader assume the time is when the thing happened.GET /api/sessions/:id/cost-analysis distinguishes six different ways to fail, and
the distinctions are the product — see the failure table
below. Corpus paths and offending lines are never echoed to the client.agents.status enumeration is five values: working, waiting, completed,
error, unknown. unknown is what the missing-Stop watchdog assigns and is a
first-class bucket in every rollup, not an error state to be hidden.NULL status is folded into unknown by the API. The status-count queries
bucket a row whose agents.status column is NULL together with the explicit
unknown rows, because an absent status is unknown and putting it anywhere else —
or nowhere — would fake certainty the database does not have. The SPA on the other side
of the wire draws a per-agent NULL as its own · unrecorded marker, since at the
level of one named agent “never recorded” and “watchdog gave up” are worth telling
apart. Both treatments are deliberate; neither invents a value.limit default 50 / max 200,
offset max 1000000, topN default 5 / max 50, DAG limit default 1000 / max 5000.
Exceeding one is a 400 with the uniform { "error": … } body, not a clamp.?limt=5 returns the default page with a 200.
Numeric parameters are coerced with JavaScript Number() semantics before the range
check, so forms such as limit=1e1 (10) and limit=0x10 (16) are accepted; only a
value that is not a number, not an integer, or out of range is a 400.
(Amended 2026-09-25 (OO).)GET /api/sessions/:id/cost-analysis is the only route that reads the JSONL substrate
live rather than the projected tables, so it has more ways to come up empty than any
other — and it answers each one differently, on purpose:
| Status | Body error |
When |
|---|---|---|
503 |
corpus access is not configured |
No corpus provider was wired at all (a DB-only deployment). The server does not guess where transcripts live. |
503 |
no corpus root is present on this machine, so nothing can be analysed |
The provider exists but there is no corpus root here. Retryable: the root is re-resolved per call, so a corpus that appears later fixes this without a restart. |
503 |
the corpus root exists but could not be read; retry shortly |
The root is there; this process could not read it on this attempt. Also retryable, and deliberately a different sentence from the one above. |
404 |
Session not found. |
The corpus was read and this session id is genuinely not in it. |
422 |
Session transcripts could not be parsed. |
A poisoned transcript — a data problem, not a server bug. |
422 |
the session transcript holds no analysable records |
The session was found and read, and holds nothing to analyse. |
A seventh shape shares the 422 status: an unpriced model raises PricingError, and its
message (model and message ids only, never a path) is returned verbatim. The route’s
declared schema additionally covers 400 for a malformed request and a detail-free 500
for anything unexpected.
The reason this table is six rows rather than one is written into the route itself: these
cases used to share a single 404 Session not found., which the code comment records as
“untrue in most cases”. With no corpus root — or one that cannot be read — nothing in
the process knows whether the session exists, so answering 404 would be stating a fact
the server has not established. The three 503s say “this server cannot answer right
now” and invite a retry; the 404 says “the answer is no” and means it.
The TypeBox schemas in packages/shared/src/schemas are the contract — every response
sets additionalProperties: false, so a field not listed here cannot appear.
GET /api/sessions → { sessions, total, limit, offset }. Each session summary is
id, projectSlug, status, startedAt, lastActivityAt (the last four nullable),
agentCount, totalTokens, totalCostUsd, unpricedTokens, and statusCounts — an
object carrying all five buckets (working, waiting, completed, error,
unknown) every time, so a bucket at zero is a stated zero rather than a gap the client
has to interpret.
GET /api/sessions/:id → the same summary shape plus edgeCount and models, a
per-model array of { model, tokens, costUsd, unpricedTokens }.
GET /api/sessions/:id/tree → { sessionId, agents, edges, agentCount, edgeCount,
unattributed }. Each agent node carries id, sessionId, type, subagentType,
status, outcomeCause, parentAgentId, firstSeenAt, lastSeenAt, totalTokens,
costUsd, unpricedTokens — outcomeCause (nullable) names why a terminal status
was assigned, so a watchdog unknown and a hook-reported one are distinguishable in the
payload. Each edge carries id, sessionId, parentAgentId, childAgentId,
source, instance, hostId, createdAt. unattributed is always present —
{ totalTokens, costUsd, unpricedTokens } for usage that resolves to no materialized
agent row. It is rendered even when it is all zeros, because the alternative is tokens
that exist in the database and appear nowhere in the tree.
GET /api/sessions/:id/events → { sessionId, events, total, limit, offset }, each
event { id, rawEventId, agentId, eventType, occurredAt, occurredAtSource }.
occurredAtSource is a union with exactly one member today, "receipt". A one-member
union looks redundant and is not: the hook envelope carries no event-originated
timestamp, so every row’s time is when the server received it, and the field says so on
every row instead of letting a reader assume it is when the thing happened. If a true
event time is ever wired, the union widens and old rows stay honestly labelled.
GET /api/cost/summary → { totals, perModel, perDay, topSessions, sessionCount,
hasMore, coverage? }. perDay uses YYYY-MM-DD keys, with the literal string "unknown" for usage
rows that carry no timestamp — again a named bucket rather than a silent omission.
topSessions is the topN costliest sessions, a slice; sessionCount is the number of
sessions with any usage that the slice was cut from, and hasMore is true when the
slice is shorter than that count — so a client can print “5 of 51” instead of hedging
that five might be the whole corpus (added 2026-09-09, closing-plan L1). coverage is
{ sessionsExcluded, sessionsQuarantined }: sessions the ingest could not read or price
on its latest attempt. Each is either absent from totals or present in it only at an
older extent than the corpus now holds — a session that ingested cleanly and was later
quarantined at the pricing gate keeps its last good pass in totals and in
/api/sessions — and the route cannot say which. The gap is still one-directional:
totals is a lower bound whenever these counts are non-zero. (Amended 2026-09-25 (OO):
this used to say excluded sessions are in “no stored total”.) It is omitted, not zeroed, when the
server has no ingest seam wired (a DB-only deployment): “we did not ask” and “we asked
and the answer is none” are different facts.
GET /api/cost/delegation-savings?topTierModel → { actualUsd, hypotheticalUsd,
savingsUsd, isEstimate, basis, sessionsTotal, sessionsWithSubagents, sessionsPriced,
skippedSessionCount, skippedSessions, subagentsPriced, subagentsSkipped, untypedAgents,
hypotheticalModels } — the corpus-wide counterpart of the per-session
delegationSavings block, summed across every session that recorded a subagent.
savingsUsd is not hypotheticalUsd − actualUsd: it is the sum over subagents of
max(0, hypothetical − actual), so a subagent that would have been cheaper on the
top-tier model contributes 0 savings while its costs still enter both totals. Example:
subagent A actual 10 / hypothetical 2, subagent B actual 1 / hypothetical 5 →
{ actualUsd: 11, hypotheticalUsd: 7, savingsUsd: 4 }. The same rule holds per session.
(Amended 2026-09-25 (OO).)
isEstimate is again the literal true, and basis is the literal
"stored-usage-rows": the sum is rebuilt from the token_usage / agents tables, not
by re-reading transcripts, and the two agree row for row on an ingested session (proved
by api-aggregate-savings-equivalence.test.ts). The scope counters are mandatory, not
extras: every excluded session is counted in skippedSessionCount (with a bounded sample
in skippedSessions), every subagent without a resolvable top-tier model in
subagentsSkipped, and agent rows with a NULL type in untypedAgents — an aggregate
quietly computed over a subset would be a lie.
GET /api/changes?since&limit&offset → { since, until, interval, basis, sessions,
totals, coverage, total, limit, offset } — the “what changed across sessions” answer
(WP-U11, daily question 5). since is required, must be an ISO-8601 instant with
a zone designator (Z or ±hh:mm) or a bare UTC date (YYYY-MM-DD), and has no default
(a delta endpoint that invents its own lower bound answers a different question than the
one asked); a malformed or non-existent instant, or a time without a zone, is a 400.
The accepted value is canonicalized to UTC and echoed back in since. One spelling is
normalized rather than rejected: ISO 8601’s end-of-day T24:00:00 (optionally .000) is
accepted and read as 00:00 of the next day, while T23:59:60 (leap second) and
T23:60 are 400. (Amended 2026-09-25 (OO).)
The window is half-open and says so: interval is the literal "(since, until]", so a
client chaining since=until neither misses nor double-counts an instant, and until is
derived from the data, never the wall clock, because ingest lags event time. basis
is the literal "event-time" — the database stores no ingest timestamp, so a session
whose activity predates since but was ingested afterwards is not in the window, a
limitation named rather than papered over. Each session row carries id, projectSlug,
status, startedAt, lastActivityAt, changedAt, change (new / updated /
unknown — unknown is a first-class answer for a session whose start is unparseable,
not a synonym for updated), agentsAppeared, tokensAdded, costAddedUsd,
unpricedTokensAdded; totals spans the whole window rather than the returned page and
includes tokensAddedOutsideChangedSessions, the residue that explains why the
per-session rows may not sum to tokensAdded; coverage counts the rows no window can
ever contain (undatedSessions, undatedAgents, undatedUsageRows). limit/offset
reuse the house caps (default 50 / max 200; offset max 1000000).
GET /api/sessions/:id/cost-analysis → { compaction, delegationSavings }.
compaction is { naiveUsd, repricedUsd, deltaUsd, compactionCount, segments }; a
materially nonzero deltaUsd is a mispricing signal and is served as-is rather than
averaged away. delegationSavings is { actualUsd, hypotheticalUsd, savingsUsd,
perAgent, skippedAgentIds, isEstimate } — savingsUsd is the sum of each perAgent
entry’s max(0, hypotheticalUsd − actualUsd), not the difference of the two session
totals (see the aggregate endpoint above) — where isEstimate is the literal true —
not a boolean that might be false. The counterfactual cache profile is not observable, so
the schema makes it impossible to serialize this figure without the label. skippedAgentIds
lists subagents with no resolvable top-tier model: excluded from the estimate and named,
because a guess would be worse than a gap.
GET /api/dag/global → { nodes, edges, counts }, where counts is
{ totalSessions, totalAgents, totalEdges, returnedAgents, returnedEdges, truncated }.
Returned-versus-total is reported separately, and truncated flips when the node cap cut
the response short — a client that renders the DAG without surfacing that flag is showing
a partial graph as if it were the whole one.
Each node carries the same fields as a session-tree node - id, sessionId, type,
subagentType, status, outcomeCause, parentAgentId, firstSeenAt, lastSeenAt,
totalTokens, costUsd, unpricedTokens - and a node’s usage is scoped to the agent’s own
session. The query groups token_usage by (agent_id, session_id) and joins on both
columns, exactly as the per-session tree does, so the two endpoints report the same figure for
the same agent. This is worth stating because nothing in the schema forbids the other reading:
no foreign key stops a usage row in session B from naming an agent id that also exists in
session A, and such a row is session B’s unattributed usage, not a contribution to the node.
(Fixed 2026-09-23: the global DAG previously grouped by agent_id alone, so a node could
report one agent id’s tokens summed across every session the id appeared in - a cross-session
total presented as one agent’s spend, and silently larger than the session tree’s figure for
the same node.)
Two guarantees hold for every figure this API can ever return, and neither is a runtime best-effort:
token_usage row the API surfaces
was copied verbatim from ~/.claude/projects/*.jsonl at projection time (WP-IN5);
the API layer performs no estimation, rounding-up, or backfilling of its own. WP-U4’s
Done-when states this precisely for the cost endpoints: “cost matches JSONL × versioned
pricing; no API-side inference.”PricingProvider
(WP-C2) resolves the price that was live at each event’s timestamp against
model_pricing.effective_from; CostEngine (WP-C3) then multiplies ground-truth
tokens by that resolved price. The API’s cost endpoints only ever surface a figure
CostEngine already computed — WP-C7’s Done-when is literally “figures match direct
engine calls (no drift).” A model+bucket combination with no priced row is a build
failure (WP-C6’s staleness gate), never a silent runtime “estimated” label — see
the cost model.As built: the no-inference guarantee holds, and an unpriced model is never costed at
$0— but the shape of the refusal differs by endpoint, and a client must handle both:
- DB rollups (
/api/sessions,/api/sessions/:id,/api/sessions/:id/tree,/api/cost/summary,/api/dag/global) resolve eachtoken_usagerow against the newestmodel_pricingrow witheffective_from <= occurred_atfor that exact(model, bucket). Rows with no resolvable rate contribute$0tocostUsdand are counted separately inunpricedTokens, which appears on every one of those payloads. The dollar figure is therefore always “cost of what could be priced”, andunpricedTokensis the declared size of what could not. A client that renderscostUsdwithoutunpricedTokensis misreporting./api/sessions/:id/cost-analysisdoes not degrade: it throwsPricingErrorand answers422naming the model. The compaction and delegation-savings figures are all-or-nothing by design, anddelegationSavingscarriesisEstimate: truein the DTO so the hypothetical can never be read as a measurement.
AMENDED 2026-09-23 (J-2). “Rows with no resolvable rate” now covers a second case that did
not exist when the bullets above were written: a model_pricing row that is there but whose
usd_per_mtok is negative, non-finite or text-coerced. On the DB-rollup endpoints that rate
resolves to NULL, so the tokens land in unpricedTokens exactly like a missing row - they are
never priced from the bad value, and the newest row’s failure is never covered up by falling
back to an older effective_from. /api/cost/summary, which is served from
token_usage_rollup, applies the same test in TypeScript (usableRate in
apps/server/src/api/queries.ts) so the two paths agree; before this it could answer 500.
The 422 bullet is unchanged and must not be read as covering this: an unknown model id
still raises PricingError from packages/core/src/cost/compute-cost.ts and halts. A bad
stored rate degrades to unpriced; an absent price halts. Those are different endpoints and
different answers.
GET /sessions/:id/tree (WP-U3) and the global DAG endpoint (WP-U4) both read from
orchestration_edges — the persisted, dual-path-derived table that is the moat artifact
itself (see the data model). Neither endpoint walks
raw events or recomputes parent→child relationships at request time; the Phase 4 exit
gate in the roadmap states this as a release-blocking property: “the tree and global DAG views are
proven to come from a query over the persisted orchestration_edges table, not a
reconstruction done in the browser from raw events.” This is also why a session’s tree
survives a missing SubagentStart hook — the edge may have been derived from the JSONL
Agent/Workflow spawn-chain path instead (WP-IN8 — the spawn tool is
Agent/Workflow, never Task) — the API has no way to tell, or need to care, which
of the two derivation paths produced a given row, because both write into the same
idempotent table before the API ever queries it.
As built: the query-not-reconstruction property held, and the API does serve
orchestration_edgesverbatim. Two corrections to the paragraph above:
- There is no
SubagentStarthook. It does not exist in Claude Code. The shipped installer registers four hooks (UserPromptSubmit,Stop,SubagentStop,PreCompact) and no hook ever asserts a parent→child edge. The DAG is built entirely from the JSONL transcripts; a session’s tree does not merely “survive” missing hooks, it never depended on them.- The API does tell you which derivation path produced a row. Every edge carries a
sourceoftool_use(observed) ordirectory/task_notification/queue_operation/legacy_explore(inferred), and the SPA is required to draw observed edges solid and inferred edges dashed behind a permanent legend. Provenance is served, not flattened.
The fifth source, and why it is named separately. legacy_explore is a heuristic
join for pre-2.1.71 bare-Explore sidecars (parser-spec gate #7), added in migration 13.
It is the weakest provenance the table can hold: where the other three inferred sources
follow a structural join path, this one matches on a name. It gets its own member of the
union rather than being folded into tool_use precisely because collapsing it would
present a name-based guess as an observed spawn — the schema comment says so in as many
words. Treat it as PROVISIONAL: it has never been observed in the real corpus and
exists in the test corpus only as the synthetic legacy-bare-explore fixture. A client
that renders edges should give it the inferred treatment, not the observed one.
POST /api/hooks/eventThis is the only write route the server exposes, and it crosses the same auth gate as
every read route. It accepts one Claude Code hook envelope and appends it to the
append-only events_raw substrate.
POST /api/hooks/event.X-Agenthropic-Delivery-Id, capped at 200 characters by the route
schema. It names one firing of one hook, so the receiver can tell a retry of the same
firing (deduplicate) from the same hook firing twice (two real events). The installer
never bakes a value into the settings file: the generated command computes it at fire
time.202 with { "stored": boolean }. stored: false is a success: it means this
delivery was already seen and the append deduplicated it.{ "error": … } shape — 400 for a malformed envelope
or an over-long delivery id, 500 (detail-free, raw error to the log only) if the
append itself fails. The 400 deliberately does not echo the offending value back.What this route may do is bounded by CD-1: hooks move liveness, never structure. The
callback the composition root supplies is UPDATE-only by construction — no hook
delivery has ever created an agent, an edge, or a token-usage row.
WP-A8, Phase 6)Update — 2026-07 (as built): this surface was CUT and does not exist.
WP-A8(operator alerts API) andWP-A9(alerts UI) were cut outright, not deferred. There is noalert_rulestable, nowebhook_targetstable, no alerts schema module, no alerts route and no alerts view anywhere in the codebase — the onlyPOSTroute the server exposes is/api/hooks/event. Alerting as a whole is v2.0 material, off the v1.0 critical path, and v2.0 is entered only through kill checkpoint KC-5, which requires evidence of real daily v1.0 use before any of it is written. If that evidence never appears, this API is never built — and the roadmap counts that as a success, not a shortfall. Everything below is the design record for a surface that was deliberately abandoned. See Telegram alerts for the full v2.0 gating story.
The alerts CRUD surface is the write side of this API and lands later than the read API
and stream above — Phase 6 (WP-A8, WP-A9, WP-A10) per the
roadmap, not Phase
alert_rules and webhook_targets. WP-A8’s Done-when: “all
write endpoints token-guarded, cross-origin rejected” — the identical gate as every
read route, with no relaxation for being a write path.webhook_targets row holds a token_ref, never the
Telegram bot secret itself (CD-10); the alerts UI built on this API “shows a target by
name only — never the underlying secret” (roadmap, Phase 6). The secret is resolved
server-side from a locally-held reference (launchd env / chmod-600) and never appears
in an API response, in SQLite, on /api/stream, or in logs.Full rule configuration (cost thresholds, stuck-agent detection, error conditions) and the Telegram delivery path this API manages are the dedicated subject of Telegram alerts, which itself ships with Phase 5’s alerting core, one phase ahead of this CRUD surface. (As built: neither the Phase 5 alerting core nor this Phase 6 CRUD surface was built; both are v2.0, KC-5-gated.)
The Status column is the design-era assessment. As built records what the shipped
code actually does.
| Claim | Status | As built |
|---|---|---|
Transport is SSE, not WebSocket; /api/stream is the path |
Fixed — CD-5; WP-U1 |
Holds. SSE, /api/stream, no WebSocket anywhere |
| Same-origin enforcement, no wildcard CORS, on the stream | Fixed — WP-U1 Done-when |
Holds — and the origin check runs before the token check, so a foreign origin is 403 even with a valid token |
Every route (read + write) is timingSafeEqual-gated |
Fixed — WP-U2, WP-A8 Done-when |
Holds for every /api/* route including /api/health and POST /api/hooks/event. WP-A8 was cut, so it contributes nothing |
| Loopback-only bind for the whole server | Fixed — WP-U0; security model rule 1 |
Holds. HOST = '127.0.0.1' is an exported constant with no configuration path |
GET /sessions/:id/tree reads orchestration_edges |
Fixed path & mechanism — WP-U3 Done-when |
Mechanism holds; path is /api/sessions/:id/tree |
| Stream is resumable | Fixed requirement; exact resume protocol (planned) | Met since 2026-09-26, bounded: Last-Event-ID replay from a 256-frame window. Until then browser auto-reconnect only, and frames sent while disconnected were lost; beyond the window, or across a restart, they still are |
| Cost/delegation/global-DAG/token/events endpoint paths | (planned shape — exact path undecided) — WP-U4/WP-U3 name the resource, not the route |
All decided: /api/cost/summary, /api/sessions/:id/cost-analysis, /api/cost/delegation-savings, /api/dag/global, /api/sessions/:id/events, and — outside the design-era list — /api/changes (WP-U11). No token-usage endpoint exists — token figures are folded into the other payloads |
| Alerts CRUD paths | (planned shape — exact path undecided) — WP-A8 names the surface, not the route |
Cut. WP-A8/WP-A9 will not be built on the v1.0 path; v2.0 requires KC-5 |
| Underlying stack (Fastify, TypeBox) | (leaning — unconfirmed) per the project’s CLAUDE.md; treated here as the working assumption because the sources name it, not because it is locked |
Confirmed and shipped: Fastify with @fastify/type-provider-typebox, additionalProperties: false on every response schema |
ANTHROPIC_API_KEY isolation, WAL + tested restore.events_raw → events →
sessions/agents/orchestration_edges/token_usage schema this API reads from.DASHBOARD_TOKEN and the server’s other
settings are supplied to the process this API runs inside.