webapi
packages/webapi/: Bun + Hono with @hono/zod-openapi, nano for CouchDB, Bun.S3Client for S3, and Scalar at /api/docs.
It is the I/O gateway (ADR 0016): consumers read through it and write only via /api/ingest/*, /api/migrate/* and /api/search/reindex. The hook writes to CouchDB and S3 directly, which is why the webapi follows CouchDB's _changes feed. In production it also serves the SPA, the docs and the CLI binary. URL list: routes.md.
Configuration
Settings from config/config.json, else the template (CT_CONFIG_DIR overrides the directory); secrets and endpoints from the environment (configuration.md).
| Variables | Default |
|---|---|
COUCHDB_URL, or COUCHDB_HOST + COUCHDB_PORT; COUCHDB_USER, COUCHDB_PASSWORD | http://127.0.0.1:7652 |
S3_ENDPOINT, S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY | http://127.0.0.1:7653, garage |
MEILI_HOST, MEILI_API_KEY (used only when features.meilisearch is on) | http://127.0.0.1:7656 |
WEBAPI_HOST, WEBAPI_PORT | 127.0.0.1:7650 |
CT_STATIC_DIR, CT_DOCS_DIR, CT_CLI_BIN | unset; each enables /app, /docs, /cli/download |
CT_VERSION | baked into the image from the git tag; reported by /health |
HTTP API
The typed routes (/api/sessions*, /api/turns, /api/search*, /api/ingest/*, /api/migrate/*) are defined with createRoute + zod, so the OpenAPI spec is generated from the code. Their parameters and response shapes are documented only there: browse /api/docs, or read /api/openapi.json (committed as openapi.json, regenerated by bun run gen:clients). Validation failures return 400 { error }.
Plain Hono routes, not in the spec: /health, /, /api/blueprint*, the /api/couch/*, /api/s3/{bucketKey}/* and /api/fossil/{repoKey}/json/* proxies (GET/HEAD only), /api/docs, /api/openapi.json, /app/*, /docs/*, /cli/download.
/api/fossil/{repoKey}/json/<command> reads the repository named by fossil.repositories.<repoKey> through Fossil's JSON API, e.g. /api/fossil/sessions/json/timeline/checkin?limit=20. GET alone doesn't make it read-only — the JSON API takes parameters from the query string, so /json/user/save?… writes — so only allowlisted commands pass (version, stat, whoami, cap, resultCodes, timeline, artifact, dir, finfo, diff, branch/list, tag/list|find, wiki/list|get|diff, report/list|get|run); anything else, a bare /json?command=… or a jsonp parameter is a 403. No client header is forwarded.
Health
GET /health always returns 200 while the process is up, so probes can use the status code for liveness. The body answers whether the stores are usable:
{ ok, status, version, startedAt, stores, sessionIndex }
ok: false,status: "degraded"when the sessions database can't be reached;stores.couch.errorsays why, andstores.couch.provisioned/provisioningErrorreport whether boot-time database creation and migrations finished.sessionIndexreportsready, the number ofsessionsheld,loadedAt,updatedAtand anyerror.ready: falsemeans the list is being served straight from CouchDB: correct but slow.
Boot never waits for the stores, so the webapi comes up even when they are broken and /health can say what is wrong. doctor checks it before writing anything.
Session list
Rows come from the session_index/aggregate view, one per session_id. A session with a summary doc is ended; otherwise running if its last event is within system.sessions.liveWindowMs (24 h), else incomplete. Filters (cwd, model, hostname, source exact; from/to by overlap) apply before paging, and totalCount is the filtered count. Each row carries activeMs: the session's span minus gaps longer than idleThresholdMs, from session_index/event_times.
Transcripts
GET /api/sessions/{id}/transcript reads from the CouchDB chunk docs by default (chunks/entries_by_session, paged at the view), so a running or crashed session is readable before SessionEnd. It falls back to the S3 blob when that covers more bytes: sessions recorded without full-content chunks, or a missed final flush. The response reports source (chunks or s3) and byteCoverage. Both sources are normalised to the same pruned per-turn shape, so raw JSONL is available only through /api/s3/sessions/<id>/transcript.jsonl. A 502 means CouchDB had nothing and S3 failed; a 404 means no transcript is stored.
Session detail reads summary:<id> and, when token_usage is missing but a transcript exists, computes it with sumTranscriptTokens.
Search
Two Meilisearch indexes named in meilisearch.indexes: sessions (metadata, one doc per session) and turns (one doc per user, assistant or tool-result turn; other transcript lines aren't indexed). They are derived from CouchDB and kept current three ways:
- the ingest routes index as they write;
- the
_changesfollower catches everything else, including the hook's writes. It checkpoints in the local document_local/search_checkpoint(local docs don't appear in_changes, so the checkpoint can't wake the follower) and on first run starts at "now"; POST /api/search/reindex(claude-transcripts reindex) clears and rebuilds, for history that predates search, renamed indexes and deletes. Unlike ingest it waits on Meilisearch's tasks and reports failures.
DELETE /api/ingest/{id} also removes the session's search entries.
Meilisearch accepts only a-zA-Z0-9, - and _ in document ids, so build them with searchDocId(). It validates batches asynchronously (202 Accepted, then failure in the task queue): if an index is unexpectedly empty, check GET /tasks on Meilisearch.
Session index
session_index/aggregate has a JavaScript reduce, which CouchDB can't serve from stored btree values when grouped per session, so querying it per request cost about 20 ms per session (8 s for ~460 sessions, #107). src/storage/session-index.ts runs the grouped query once at boot, then re-reads only the sessions each _changes batch touches; the route filters, sorts and pages in memory (5–70 ms).
- It is a cache: rows are stored as the view returns them, and
reindexstill queries CouchDB. - It fails soft: while cold or after a failed load the route queries the view directly; a failed reload keeps the last good data.
- A full reload runs every 15 minutes in case the feed died, and
/healthreports its state.
The change follower therefore runs even with Meilisearch off.
Storage and boot
- CouchDB (
src/storage/couch.ts):db(key)opens a database by its logical key incouchdb.databases. - S3 (
src/storage/s3-blob-store.ts): path-style addressing (Garage, MinIO), so switching provider is an environment change. Writes only viaPUT /api/ingest/{id}/transcriptandDELETE /api/ingest/{id}?blobs=true. Never creates the bucket. - Boot (
src/storage/ensure.ts, idempotent): creates CouchDB's system databases and every configured database, applies pending migrations, creates a Mango index ontype, then starts the session index and the change follower.
Serving the SPA and docs
With CT_STATIC_DIR set, the SPA is served under /app/ with an index.html fallback for client-side routes (Vite builds with base: "/app/"). In development the variable is unset and Vite serves the UI, proxying /api here.
src/caching.ts:
- Compression wraps Hono's
compress()for every response. The wrapper measures the body itself, because a handler-builtResponsehas noContent-Lengthand the stock threshold never fires. Binary bodies,text/event-streamand proxied continuous CouchDB feeds are not compressed. It must be registered before the routes. - Caching:
/app/assets/*(content-hashed) getspublic, max-age=31536000, immutable; the SPA shell and/docs/*getno-cachewith an ETag.
packages/shared
Cross-cutting types (TokenUsage, SessionStatus, SessionSummary, ...) and helpers. sumTranscriptTokens(jsonl) sums token usage from a transcript, deduplicating by message.id and keeping the largest usage block per id, so streamed duplicates aren't counted twice. The webapi and the hook import the same copy. The webui and CLI take their wire types from the generated clients.