The read API and the private ingest API — their routes, their seals, and the trust boundary between them.
Tapes publishes two separate contracts because reading derived telemetry and ingesting trusted captures have different trust models.
Read API
Section titled “Read API”The default read API listens on :8081. It serves health, derived data, operator maintenance, MCP, and its own OpenAPI contract. Search and skills are served by their cassettes under /v1/cassettes/search and /v1/cassettes/skills.
| Area | Routes |
|---|---|
| Health and contract | GET /ping, GET /openapi, GET /swagger, GET /metrics |
| Browser UI | GET /, served only with --api-web-ui |
| Sessions | /v1/sessions, /v1/sessions/{id}, /v1/sessions/{id}/traces, /v1/sessions/{id}/raw_turns |
| Traces and spans | /v1/traces, /v1/traces/{trace_id}, /v1/traces/{trace_id}/spans/{span_id} |
| Aggregates | GET /v1/stats |
| MCP | /v1/mcp |
| Operator actions | /v1/admin/derive/run, /v1/admin/seed/demo, /v1/admin/raw-turns/attribution-repair |
| Cassettes | GET /v1/cassettes, GET /v1/cassettes/{name}/openapi.json, /v1/cassettes/{name}, /v1/cassettes/{name}/* |
GET /metrics is Prometheus exposition, deliberately outside any auth group and not described in the contract. GET / is HTML, not API surface, and is not described either.
The authoritative parameters, schemas, and methods are compiled from route registrations and served by the running API at GET /openapi. The aggregate includes admitted cassette operations. See Cassettes for their manifest and proxy contract. Notable current behavior:
- session listing is cursor-paginated;
- session and trace/span paths use UUID IDs;
- session content is read through traces and spans;
- semantic search is served by the search cassette (
/v1/cassettes/search/spans); - raw turns remain available at
/v1/sessions/{id}/raw_turns.
There is no /v1/search, /v1/sessions/summary, or hash-based session route.
Both contracts are sealed
Section titled “Both contracts are sealed”No generated OpenAPI document is checked in — a copy of what the server states
exactly is a copy that can go stale. What is checked in is a seal: api/CONTRACT
and ingest/CONTRACT each hold a sha256 fingerprint of that surface’s compiled
document with prose stripped out.
A test recompiles the document and compares. Move a route, a parameter, a schema,
or a status code and the test fails until the new value is written into the
CONTRACT file — which turns “this changes a published contract” into a line in
the diff instead of something a reviewer has to notice. Editing a doc comment is
deliberately not a contract event; text declared inline on a route registration
is published surface and does move the seal.
Consumers of these contracts live outside this repository, which is why the ingest surface in particular is sealed rather than merely tested: an unannounced change to it is one every capture adapter discovers in production.
Attribution repair
Section titled “Attribution repair”POST /v1/admin/raw-turns/attribution-repair records an audited, append-only attribution correction for exactly one raw turn — selected by raw_turn_id or paper_proxy_request_id — without modifying raw_turns, then synchronously re-derives the previous and effective sessions.
A 200 is a completed repair. A 202 means the correction committed and is effective, but the synchronous projection rebuild did not finish: projections_pending names the stale sessions, which the derive worker converges on its own — the repair is recorded, so do not retry it.
source_cleanup_pending is independent of the status code and can accompany either. It discloses an emptied source-session row the cleanup step failed to delete: cosmetic, anchoring no effective turns, and — unlike projections_pending — retried by nothing. It does not resolve on its own.
Private ingest API
Section titled “Private ingest API”The private ingest API defaults to :8082 and serves its separate contract at GET /openapi. The all-in-one tapes serve stack starts it alongside the proxy and read API; tapes serve ingest runs it as a standalone sidecar. Its write routes are:
POST /v1/ingest— append one completed conversation turn;POST /v1/ingest/transcript— append one harness transcript file or spawn-anchor row;GET /ping— health.
A transcript payload is the main session transcript, one subagent’s transcript, or a Codex spawn-anchor row (a sub_agent_activity rollout record). agent_id and tool_use_id carry the subagent fork edge the deriver reconciles against the wire capture. The optional kind field qualifies Codex anchor rows: absent or empty means spawn evidence; "interacted" marks a re-entry record (send_message, followup_task) that is stored for future rendering and deliberately ignored by derivation. Rows deduplicate on a structured hash of harness ID, harness session ID, tagged main-vs-agent identity, lifecycle kind, and records content. Re-uploading unchanged content is therefore a no-op while a grown transcript appends a new version; the deriver reads the latest version per tagged (main or agent, lifecycle kind), so an agent literally named main cannot shadow the main file and an interacted row never supersedes a spawn anchor. Empty and started remain the same spawn lifecycle for compatibility with older uploaders.
The transcript write transaction also upserts the session identity, widens started_at and last_seen_at from the earliest and latest valid record timestamps (using ingest time when none parse), and marks derivation dirty. Deduplicated uploads still requeue derivation. ended_at remains unset because uploading a file does not prove that the harness session ended.
When a session has no successfully parsed main-conversation wire call, those latest transcript versions produce a partial trace/span projection with source: "transcript". One main wire call switches the conversation to wire projection; transcript rows then reconcile structure only and never duplicate fallback conversation content. Shadow wire calls (such as title, permission, and suggestion calls) remain alongside transcript conversation content rather than hiding it. A compaction-only wire capture behaves the same way and can form a compaction seam into the transcript continuation. Unknown transcript records stay in the raw-turn response and appear as omitted types in the derive report rather than failing valid records.
Run the standalone form only for sidecar/gateway capture:
tapes serve ingest --postgres "$TAPES_STORAGE_POSTGRES_DSN"Request bodies are capped at roughly 14.67 MiB. A POST over the limit is rejected with 413 and the surface’s standard JSON error envelope ({"error": "..."}), the same shape as every other rejection, so capture adapters can parse all failures uniformly. Each body-limit rejection is counted in tapes_ingest_writes_total{provider="unknown",status="reject_oversize"} (the body is never parsed, so the provider is unknown) and logged with the declared content length, the configured limit, and the request path.
The ingest server appends to immutable raw_turns; it does not provide the read API. Treat it as a private trusted write surface, not as a public application endpoint. Authentication, network policy, and gateway grants are deployment responsibilities.
Provider proxy
Section titled “Provider proxy”The capture proxy defaults to :8080. It exposes provider-compatible request paths, not the Tapes read contract. Clients send LLM traffic to the proxy; they send inspection/search requests to :8081.
Internal listener
Section titled “Internal listener”Optional, off unless TAPES_INTERNAL_LISTEN names an address, and documented
to use :8092. It carries exactly one endpoint, GET /internal/readiness/evidence, which reports what this process loaded and what
admitting that configuration produced — instance identity, a digest of the
cassette source list in effect, and each source’s admission result. It is not
part of the read API’s sealed contract and does not appear in its OpenAPI
document, deliberately: a deployment that puts a gateway in front of the read
API may rewrite a public path prefix onto its root, which would make any path
added there publicly reachable. Expose this as a container port and keep it off
the Service. Every request must present TAPES_INTERNAL_TOKEN as a bearer
token. See Configuration.
CORS and exposure
Section titled “CORS and exposure”Do not infer a production security boundary from local listen defaults or generated OpenAPI. Choose network exposure, TLS, authentication, tenant headers, and access control for the deployment environment. Tapes documentation intentionally does not prescribe a hosting redirect or public deployment topology.