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}/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.
Session traces are paged and streamed
Section titled “Session traces are paged and streamed”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.
Trace detail is paged and streamed
Section titled “Trace detail is paged and streamed”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.
Session transcripts
Section titled “Session transcripts”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).
Span payload modes
Section titled “Span payload modes”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.
Raw turns are paged by id
Section titled “Raw turns are paged by id”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.
Raw turn sizes are capture-time metadata
Section titled “Raw turn sizes are capture-time metadata”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_bytesis how many verbatim upstream response bytes the row retained, as they arrived on the wire and before any reduction;0when none were kept.raw_response_droppedistruewhen the verbatim response existed but was not retained, because it exceeded the 8 MiB ingest cap or the producer withheld it.raw_response_bytes: 0with this flag set is a fidelity gap — the turn cannot be re-derived from its source bytes — not a turn that never had any.
Deleting a session
Section titled “Deleting a session”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.
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.
Subjects
Section titled “Subjects”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 distinctauth_subjecton captured sessions, most recently active first:{"items": [{"subject", "session_count", "last_seen_at"}]}.qkeeps ids that contain it.GET /v1/sessions?auth_subject=<id>narrows the session list to one subject (exact match).
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.
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.
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, 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. |
Session envelope and fork lineage
Section titled “Session envelope and fork lineage”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:
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.
Trusted identity headers
Section titled “Trusted identity headers”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.
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.