---
title: HTTP APIs
description: The read API and the private ingest API — their routes, their seals, and the trust boundary between them.
editUrl: https://github.com/papercomputeco/tapes/edit/main/docs/apis.md
sourceRepo: papercomputeco/tapes
sourcePath: docs/apis.md
lastUpdated: 2026-10-16
---

Tapes publishes two separate contracts because reading derived telemetry and ingesting trusted captures have different trust models.

## 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](/docs/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

`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

`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

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

```json
{
  "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](/docs/mcp/)).

### 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

`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

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.

### 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](#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](/docs/configuration/#read-guards)): a gateway may answer the
client with a `504` while the delete goes on to commit.

### 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

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](#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).

### 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

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

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:

```bash
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

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

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

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](/docs/configuration/).

## 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.