Skip to content

HTTP APIs

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.

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}/transcript, /v1/sessions/{id}/raw_turns
Subjects GET /v1/subjects
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, and the composite session view is paged and streamed (see below);
  • a readable conversation is served at /v1/sessions/{id}/transcript (see below);
  • semantic search is served by the search cassette (/v1/cassettes/search/spans);
  • raw turns remain available at /v1/sessions/{id}/raw_turns, paged the same way (see below).

There is no /v1/search, /v1/sessions/summary, or hash-based session route.

GET /v1/sessions/{id}/traces returns one page of the composite view, not the whole session. A page is the next limit traces in turn order (default 50, maximum 200; a larger value is clamped, and anything that is not a positive integer is rejected with 400). A page also closes early, at the next trace boundary, once it has emitted roughly 8 MiB before compression — spans carry whole tool results and images, so a count alone does not bound a page, and a page may therefore hold a single trace.

Every page carries the whole envelope: schema, session, and the session-scoped links are complete on each one; only traces is paged. When more traces remain the page ends with next_cursor; pass it back as cursor to continue. Its absence is what marks the session’s last trace — a page shorter than limit does not. A cursor is opaque and bound to the session it was minted for; presenting it on another session is a 400.

The body is written as it is read: the server holds the payload-free turn headers and links, then streams each span through as its row is scanned, so memory stays bounded by one span regardless of session size. The response is still one JSON document per page, so existing clients that parse the body whole keep working; they only need to follow next_cursor where they previously assumed one response was everything. Because the status is committed before the first span is read, a failure mid-page truncates the body rather than producing an error response.

GET /v1/traces/{trace_id} pages the same way, over spans instead of traces. A page is the trace’s header plus its next limit spans in presentation order (seq; default 200, maximum 1000; a larger value is clamped, and anything that is not a positive integer is rejected with 400). It closes early at the next span boundary once it has emitted roughly 8 MiB before compression — the same budget the composite uses — so a page may hold a single span.

Every page carries the whole envelope: session_id, schema, the trace header (its span_count is the trace’s whole count, not the page’s), and the links touching the trace are complete on each one; only spans is paged. When more spans remain the page ends with next_cursor; pass it back as cursor to continue. Its absence is what marks the trace’s last span — a page shorter than limit does not. A cursor is opaque and bound to the trace it was minted for; presenting it on another trace is a 400. The field never appears on the trace copies embedded in the composite, where a trace is served whole.

The body is written as it is read, in both payload modes: the server holds the payload-free header and links, then streams each span through as its row is scanned, so memory stays bounded by one span regardless of the trace’s length. A page that holds the whole trace is byte-identical to the document the endpoint used to return whole, so clients that parse the body as one document keep working; they only need to follow next_cursor where they previously assumed one response was everything.

GET /v1/sessions/{id}/transcript reads a session as one conversation, classified on the server so every client renders the same thing:

{
"session_id": "0f0e…",
"items": [
{"seq": 2, "trace_id": "trc_…", "span_id": "llm_…", "started_at": "…",
"role": "user", "text": "Fix the failing test"},
{"seq": 2, "trace_id": "trc_…", "span_id": "llm_…", "started_at": "…",
"role": "assistant", "text": "Looking at it now.",
"tool_calls": [{"id": "toolu_1", "name": "Bash", "input": {"command": "make test"}}]},
{"seq": 3, "trace_id": "trc_…", "span_id": "toolu_1", "started_at": "…",
"role": "user", "text": "",
"tool_results": [{"tool_use_id": "toolu_1", "text": "FAIL …", "is_error": true}]}
],
"next_cursor": "…"
}

Rows come from the span projection in turn order, then seq within a turn. Only conversation llm calls (call_kind main, or empty on older rows) contribute: each yields a user row for the content new to that call and an assistant row for the reply. Harness side-calls (permission checks, title generation, suggestions) and injected context are left out. User text has harness tags such as <system-reminder> stripped — the same projection the trace header’s user_prompt uses, without its truncation. Assistant text is the reply’s text blocks; thinking is not included. Server-side tools (web search) return their results inline in the reply, so those ride on the assistant row’s tool_results. Tool results never land in text: a run of tool spans becomes one user row whose tool_results hold them, which is where they sit on the wire. A row’s seq, trace_id, and span_id locate it in the trace view; a tool-results row points at its first tool span. thread_id appears only on subagent rows.

Parameter Meaning
role user, assistant, or all (default). user keeps only rows with user text, so tool-result-only rows drop out.
q Keep rows whose text contains this substring, case-insensitively.
subagents true includes subagent threads; the default is the root thread only.
limit Rows per page, default 200, maximum 1000 (clamped; non-positive is a 400).
cursor The previous page’s next_cursor; opaque and bound to the session.

A page closes at limit rows or once its rows pass roughly 8 MiB, whichever comes first. next_cursor is present only when another row matching the filters exists, so its absence — not a short page — marks the end. The server reads spans one at a time while it walks, so memory stays bounded by one page regardless of session size; a narrow q may read much further than the rows it returns. Each read takes a payload-read slot, like the trace page (payload=preview does not exempt it). An unknown session is a 404. The same page is available to MCP clients as the core transcript tool (see MCP).

GET /v1/sessions/{id}/traces and GET /v1/traces/{trace_id} take payload=full (the default) or payload=preview. Full mode embeds each span’s stored input and output content-block arrays verbatim. Preview mode is for list-shaped reads: every string is bounded to 512 runes, image bytes are dropped, and nested tool arguments are truncated in place, so a whole session of previews stays smaller than one full tool result. A preview span carries payload: "preview"; fetch GET /v1/traces/{trace_id}/spans/{span_id} for its full content.

Previews are computed once, by the deriver, and stored beside the payload (input_preview / output_preview on the spans table). A preview read selects only those columns and the span header — never input or output — so it costs what the header read costs regardless of payload size. Nothing is truncated at read time.

Spans derived before the preview columns existed have no stored preview yet. Preview mode serves such a span with input and output pinned to [] and payload: "preview_pending", and does not consult the payload; the span endpoint still serves its full content. A backfill fills the columns for those rows, after which they are served as preview. The payload query parameter accepts only full and preview; preview_pending is a response marker, not a mode.

Preview content is served as it was stored. Postgres canonicalizes JSONB, so the key order inside a preview block is whatever the store returns, and it is not part of the contract; compare previews as decoded JSON. Full mode is unaffected: its bytes are the stored payload’s.

GET /v1/sessions/{id}/raw_turns is paged like the other two reads, over raw turn headers. A page is the next limit headers in raw turn id order (default 200, maximum 1000; a larger value is clamped, and anything that is not a positive integer is rejected with 400). The listing is payload-free, so the count alone bounds a page: there is no byte budget and the body is served whole, not streamed. When more headers remain the page ends with next_cursor; pass it back as cursor to continue. Its absence is what marks the session’s last raw turn — a page shorter than limit does not. A cursor is opaque and bound to the session it was minted for; presenting it on another session is a 400.

The wire log lists one header per captured call or transcript push, identity and sizes only, never the payloads. Its request_bytes and response_bytes are the sizes the capture adapter recorded when the turn crossed the wire (request_bytes and response_bytes in the row’s meta, which both capture paths — tapes-extproc and the in-repo proxy — write), not a measurement of the stored payloads. Measuring would mean detoasting and re-serializing every blob in the session to count it — the cost a header listing exists to avoid — so the stored rows are never read for their size. A producer that did not record a size, or recorded one that is not a number, reports 0; one malformed value does not fail the listing.

Each header also reports the raw layer’s own fidelity:

  • raw_response_bytes is how many verbatim upstream response bytes the row retained, as they arrived on the wire and before any reduction; 0 when none were kept.
  • raw_response_dropped is true when the verbatim response existed but was not retained, because it exceeded the 8 MiB ingest cap or the producer withheld it. raw_response_bytes: 0 with this flag set is a fidelity gap — the turn cannot be re-derived from its source bytes — not a turn that never had any.

DELETE /v1/sessions/{id} permanently removes the session and the data captured for it, and answers 204, or 404 for an unknown id. It removes:

  • the session and every subagent session beneath it;
  • their traces, spans, and span links;
  • every raw turn attributed to any of those sessions, with the attribution corrections recorded against those turns;
  • the sessions’ pending derive work.

All of it goes in one transaction, so a failed delete leaves the session and its capture whole. Raw turns are almost all of a tenant’s stored data, so deleting a session frees its share of the tenant’s storage straight away. Postgres reuses the space after vacuum rather than shrinking its files, so the database’s on-disk size does not drop at the same moment.

A raw turn belongs to the session its attribution names: the latest attribution repair when one exists, otherwise the harness session it was captured under. A turn repaired onto another session belongs to that session and is kept; a turn repaired onto the deleted session is removed with it. No raw turn belongs to two sessions.

Re-deriving cannot bring a deleted session back: derivation never creates a session, and its raw turns are gone. If the agent is still running, its next captured turn or transcript upload starts a new session with a new id, holding only what was captured after the delete. Most harnesses re-send the conversation so far with each request, and a transcript upload carries the whole file, so that new session can show earlier messages again.

The delete waits for any in-flight derive or attribution repair of the same sessions to finish before it runs. A turn captured while the delete runs is ordered against it: it is either visible to the delete and removed with the session, or written once the delete commits and starts the new session. A subagent session that attaches to the deleted subtree while the delete runs is deleted with it, raw turns included; one whose capture arrives after the delete commits starts afresh. It takes as long as the session is large, so it is exempt from the read deadline (see Read guards): a gateway may answer the client with a 504 while the delete goes on to commit.

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.

Every captured session carries an auth_subject: an opaque id for whoever the capture belongs to — the subject a trusted gateway vouched for (see trusted identity headers), or the capture client’s own claim, such as tapesctl start --auth-subject (default local:<user>). tapes stores only the id; mapping it to a person, if a deployment wants that, is the job of whatever fronts tapes.

  • GET /v1/subjects?q=&limit= lists one row per distinct auth_subject on captured sessions, most recently active first: {"items": [{"subject", "session_count", "last_seen_at"}]}. q keeps ids that contain it.
  • GET /v1/sessions?auth_subject=<id> narrows the session list to one subject (exact match).

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.

A repair is a complete replacement attribution. Its optional parent_harness_id names the parent’s harness beside parent_harness_session_id, and requires it; when omitted, the parent resolves in the repaired harness_id, even if the captured envelope named another parent harness.

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.

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, and the server’s capabilities.

GET /ping answers {"status":"ok","capabilities":[…]}. Each capability names an optional envelope behaviour this server implements, so a capture adapter can check before it sends a field whose meaning an older server would not share: ingest ignores a field it does not know rather than refusing it. A server that predates the list omits capabilities; treat that, and a failed probe, as none.

Capability Meaning
parent_harness_id A fork parent resolves under the envelope’s parent_harness_id. Without it, the field is ignored and the parent is looked up in the child’s harness.

A POST /v1/ingest turn carries an optional session envelope: the harness identity (harness_id, harness_session_id, harness_version, cwd, name, harness_metadata) plus the caller’s org_id and auth_subject. Capture adapters build it from the X-Tapes-* request headers; the header-to-field mapping and its edge cases are pinned by the language-neutral corpus in fixtures/envelope/.

harness_id is an open set (claude, codex, pi, hermes, …) stored verbatim; a missing or empty one is unknown.

A session launched by another session names its parent with parent_harness_session_id (header X-Tapes-Parent-Harness-Session-Id). The parent may run under a different harness than the child — a Claude session that launches a Codex one — and parent_harness_id (header X-Tapes-Parent-Harness-Id) names that harness. Ingest resolves the parent by (org_id, parent_harness_id, parent_harness_session_id); when parent_harness_id is absent it uses the child’s own harness_id, so a producer that never sends it keeps same-harness lineage. Send both for a cross-harness parent: without parent_harness_id, the parent is looked up in the child’s harness and the link misses. A server that does not advertise the parent_harness_id capability behaves that way whatever the adapter sends, so an adapter talking to one should leave a cross-harness parent off the row rather than create a placeholder in the wrong harness.

The parent’s first turn does not have to arrive first. A child that names an unseen parent creates a placeholder session under the parent’s harness, and the parent’s own first turn fills that row in place, so the child’s parent_session_id stays correct either way.

Both fields must be omitted rather than sent empty in a JSON body (400). An empty header is dropped by the reader instead, and an empty X-Tapes-Parent-Harness-Id never becomes unknown. A parent_harness_id without a parent_harness_session_id names no session and is ignored.

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:

Terminal window
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 gateway, not the client, vouches for who a capture belongs to. Set TAPES_TRUSTED_SUBJECT_HEADER to the header your gateway stamps with the verified subject, on both tapes serve ingest (transcript pushes) and tapes-extproc (wire capture); its value is stored as auth_subject. It is unset by default: a standalone install with no gateway in front trusts no header and keeps the identity the capture client reported. Clients must not be able to send the configured header themselves; stripping it at the edge is the gateway’s job.

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.

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.

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.

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.