# Read API

> Compiled GET /openapi for :8081: sessions, traces, spans, stats, search, skills, admin, MCP, cassette proxy, and CONTRACT seal rules.

- Repository: papercomputeco/tapes
- GitHub: https://github.com/papercomputeco/tapes
- Human docs: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750
- Complete Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/llms-full.txt

## Source Files

- `api/openapi_routes.go`
- `api/openapi.go`
- `api/sessions_handlers.go`
- `api/admin_handlers.go`
- `api/CONTRACT`
- `docs/apis.md`
- `api/v1_handlers.go`

---

---
title: "Read API"
description: "Compiled GET /openapi for :8081: sessions, traces, spans, stats, search, skills, admin, MCP, cassette proxy, and CONTRACT seal rules."
---

The read API is the Fiber server started by `tapes serve` (or `tapes serve api`) on `api.listen`, default `:8081`. It compiles its OpenAPI document from the same `oasfiber` registrations that mount the routes, then serves that document at `GET /openapi`. A running process merges admitted cassette operations into that document; `api/CONTRACT` seals only the **core** document (no cassettes, no field doc comments). Ingest writes stay on `:8082`. Capture clients talk to the provider proxy on `:8080`, not this surface.

```mermaid
flowchart LR
  subgraph clients [Callers]
    tapesctl["tapesctl / consoles"]
    mcpClient["MCP clients"]
    scrape["Prometheus / Alloy"]
  end
  subgraph read [":8081 read API"]
    ping["GET /ping"]
    openapi["GET /openapi"]
    core["/v1 sessions traces stats search skills admin mcp"]
    cass["/v1/cassettes/{name}/* proxy"]
    metrics["GET /metrics"]
    swagger["GET /swagger"]
  end
  subgraph notRead [Not this contract]
    ingest[":8082 POST /v1/ingest"]
    proxy[":8080 provider paths"]
  end
  tapesctl --> core
  tapesctl --> openapi
  mcpClient --> core
  scrape --> metrics
  openapi --> cass
  core -.-> ingest
  core -.-> proxy
```

<Note>
`GET /openapi` is the live contract. There is no checked-in `openapi.yaml`. `tapes dev openapi [api|ingest]` exists only to fold per-field Go doc comments from a checkout; a deployed binary has no source tree, so the served document carries route/operation prose but not field prose.
</Note>

## Listen, compile, and view

| Item | Value |
| --- | --- |
| Default listen | `:8081` (`api.listen`, `TAPES_API_LISTEN`) |
| Default client target | `http://localhost:8081` |
| OpenAPI info | title `Tapes API`, version `1.0` |
| Compile target | OpenAPI 3.0 |
| Browser viewer | `GET /swagger` (Scalar, `data-url="/openapi"`) |
| Optional HTML UI | `GET /` only when `--api-web-ui` / `api.web_ui` is set |
| Metrics | `GET /metrics` Prometheus exposition, unauthenticated, not in the contract |

<CodeGroup>

```bash title="What the process serves"
curl -sS http://localhost:8081/ping
curl -sS http://localhost:8081/openapi | head
```

```bash title="Fully documented compile from a checkout"
tapes dev openapi api                  # YAML + field prose from .
tapes dev openapi api --format json
tapes dev openapi api --docs-root ''   # shapes only; this is the sealed document
tapes dev openapi api --out api-contract.yaml
```

</CodeGroup>

`make contracts` writes the prose-included documents into `./build/contracts`. Nothing in this repository consumes those files; they are for consumers that want bytes on disk.

Responses are gzip-compressed when the client sends `Accept-Encoding`. Trace/session JSON is large; compression is applied to the whole app after request-id and RED metrics middleware.

## Published vs unpublished routes

Registration and documentation are the same call in `api/openapi_routes.go`. Coverage tests fail if a Fiber route is missing from the compiled document, except the exemptions below.

| Path | In `GET /openapi`? | Role |
| --- | --- | --- |
| `GET /ping` | yes | Health; body is the JSON string `"pong"` |
| `GET /openapi` | no | Serves the merged document (circular if described) |
| `GET /swagger` | no | HTML viewer |
| `GET /metrics` | no | Prometheus scrape |
| `GET /` | no | Optional web UI |
| `/v1/sessions…`, `/v1/traces…`, `/v1/stats`, `/v1/search/spans`, `/v1/skills…`, `/v1/admin…`, `/v1/mcp` | yes | Core surface |
| `GET /v1/cassettes` | yes | Discovery |
| `GET /v1/cassettes/{name}/openapi.json` | no | Cached per-cassette spec |
| `/v1/cassettes/{name}` and `/*` | no | Reverse-proxy mount; concrete cassette ops appear under their rewritten paths in the **merged** `/openapi` |

There is no `/v1/search`, `/v1/sessions/summary`, or hash-addressed session route. Session and trace IDs on core paths are UUIDs.

## Error envelope and 501 capability gates

Core non-2xx bodies use `{"error": "<message>"}` (`llm.ErrorResponse`). Cassette discovery/proxy failures use a **different** object: `{"error": "<stable_code>", "message": "<prose>"}` (`unknown_cassette`, `spec_unavailable`, `aggregate_failed`, `bad_target`, `cassette_unavailable`).

Many handlers type-assert the storage driver. A backend that lacks the capability returns **501** with a stable message (`sessions not supported by this backend`, `span traces not supported by this backend`, `skills not supported by this backend`, `driver does not host the raw-turn layer`, and similar). Postgres implements the product surface; in-memory drivers used in tests often do not.

Tenancy is not a request header. Every handler scopes to the sentinel org `00000000-0000-0000-0000-000000000000`. `X-Tapes-Org-Id` is ignored. Gateway-stamped `x-paper-auth-subject` is a **filter / authorship** header (sessions list/stats, skill create/delete), not a tenant switch.

<Warning>
Do not treat `:8081` or the generated OpenAPI as a production security boundary. Network exposure, TLS, authentication, and who may call admin routes are deployment choices. This server does not implement ingest writes.
</Warning>

## Sessions, stats, and export

Session rows split **identity** (ingest-written: harness ids, `auth_subject`, `name`) from **`rollup`** (deriver-written: status, title, counts, usage). Clients should render `display_title`, not `name`.

`display_title` resolution, in order: `display_name` (PATCH) → generated `rollup.title` → non-JSON `preview` → harness `name` → 12-character `harness_session_id` slice → session id. Never empty.

`live` is true when `ended_at` is null and `last_seen_at` is within **5 minutes**. It is computed at response time and is not `rollup.status`.

:::endpoint GET /v1/sessions List sessions
Cursor-paginated session table. Default sort `last_active` (`last_seen_at`) `desc`.

<ParamField query="limit" type="integer">Default 50, max 200. Ignored on the harness point-lookup path.</ParamField>
<ParamField query="cursor" type="string">Opaque keyset cursor from `next_cursor`.</ParamField>
<ParamField query="sort" type="string">`last_active` \| `started_at` \| `turn_count` \| `total_cost_usd` \| `total_tokens` \| `duration_ns` \| `derived_status` \| `auth_subject`.</ParamField>
<ParamField query="direction" type="string">`asc` \| `desc` (default `desc`).</ParamField>
<ParamField query="since" type="string">RFC3339 activity window (turn started at or after). Same window semantics as `/v1/stats`.</ParamField>
<ParamField query="until" type="string">RFC3339 activity window (turn started before).</ParamField>
<ParamField query="harness_id" type="string">Only with `harness_session_id`. Alone is 400. Incompatible with cursor, sort, direction, since, until.</ParamField>
<ParamField query="harness_session_id" type="string">Exact match. Alone: at most one row per harness. With `harness_id`: single-harness lookup.</ParamField>
<ParamField query="auth_subject" type="string">Exact JWT subject. Ignored on the harness filter path.</ParamField>

<ResponseField name="items" type="SessionItem[]">One row per harness session.</ResponseField>
<ResponseField name="next_cursor" type="string">Absent on the last page.</ResponseField>
:::

:::endpoint GET /v1/sessions/{id} Get a session
Returns `{ "session": SessionItem }`. Conversation content is on `GET /v1/sessions/{id}/traces`.
:::

:::endpoint PATCH /v1/sessions/{id} Update display_name
Body must include `display_name`. Null or empty (after trim) clears the rename. Max **200** characters. 400 if the field is absent.
:::

:::endpoint DELETE /v1/sessions/{id} Delete a session
**204**. Cascades subagent child sessions and derived traces/spans. Leaves immutable `raw_turns` intact.
:::

:::endpoint GET /v1/sessions/{id}/traces Session traces and spans
Composite projection: `{ schema, session, traces[], links[] }`. `schema` is currently `2026-06-15`. Cross-trace links (compaction seams) sit at the top level.

<ParamField query="payload" type="string">`full` (default) or `preview`. Preview truncates payload strings to 512 runes plus `…`, strips image bytes, and sets span `payload` so clients can drill in.</ParamField>
:::

:::endpoint GET /v1/sessions/{id}/raw_turns Raw capture headers
Operator wire log: identity and sizes only. `source` is wire vs transcript push. No payload blobs.
:::

:::endpoint GET /v1/sessions/{id}/export Export one session as JSONL
One NDJSON line: session + traces (+ full spans unless `detail=traces`). `Content-Type: application/x-ndjson`. Attachment filename `session-{id}-{date}.jsonl` (or `*-traces.jsonl`). Rendered to a buffer first so failures stay `application/json`.
:::

:::endpoint GET /v1/sessions/export Export a window as JSONL
Streams one JSON line per session, newest-first. Default window: trailing **30 days**. Pages internally at 200 so it is not bounded by the list cap. Same `detail` enum. Headers are committed once streaming starts.
:::

:::endpoint GET /v1/stats Aggregate rollups
One row for the window. Numbers are span-grain **trace** rollups (delta-only usage; `turn_count` counts traces; `total_duration_ms` is summed trace duration in milliseconds, not stored nanoseconds). `auth_subject` narrows totals; a subject with no rows returns zeros, not 404.

<ResponseExample>

```json
{
  "session_count": 12,
  "turn_count": 40,
  "completed_count": 3,
  "total_cost": 1.25,
  "input_tokens": 80000,
  "output_tokens": 12000,
  "total_duration_ms": 540000,
  "tool_calls": 18
}
```

</ResponseExample>
:::

## Traces and spans

:::endpoint GET /v1/traces?session_id= List turn headers
Required `session_id` (UUID). No span payloads. Response `{ "schema": "2026-06-15", "items": [TraceItem] }`.
:::

:::endpoint GET /v1/traces/{trace_id} One turn with spans
Spans nested by `parent_span_id`. `links` include edges that touch other traces. Same `payload=full|preview` as the session composite. Standalone responses stamp `schema`; embedded copies in the composite do not.
:::

:::endpoint GET /v1/traces/{trace_id}/spans/{span_id} Full span payloads
Always full input/output content blocks. Use this after a preview list.
:::

`SpanItem` fields that matter on the wire: `kind`, `name`, `status`, `call_kind`, `model`, `stop_reason`, `thread_id`, `raw_turn_id`, `verdict` (object or null), `input`/`output` (content-block arrays, pinned `[]` when empty), `usage` (object, pinned `{}` when empty), optional `payload`.

`SpanLinkItem.kind` is typed: rejoin / verdict / compaction-seam / emits / feeds.

`TraceItem.user_prompt` is never omitted: empty string means a synthetic opener. `usage` is all LLM spend on the turn (shadow included); `main_usage` is `call_kind=main` across threads.

## Search

:::endpoint GET /v1/search/spans Semantic span search
Embeds `query` and searches the span-embedding projection (main-conversation LLM spans, delta-only content).

<ParamField query="query" type="string" required>Search text.</ParamField>
<ParamField query="top_k" type="integer">Default 5, minimum 1.</ParamField>

**400** missing/invalid query. **503** if embedder or span store is not configured, or the embedding projection is not initialized yet. **500** embed or search failure.

<ResponseField name="query" type="string">Echo of the request.</ResponseField>
<ResponseField name="results" type="SpanSearchResult[]">`trace_id`, `span_id`, `session_id`, `score`, `user_prompt`, `snippet`, `model`, `started_at`.</ResponseField>
<ResponseField name="count" type="integer">`len(results)`.</ResponseField>
:::

The same implementation backs the legacy MCP tool `search`.

## Skills

Skill JSON uses **camelCase** on the skill store (`sessionIds`, `parentId`, `isAiGenerated`, …), unlike the snake_case session/trace surface. The route key is the opaque `id`; `slug` is display-only and not addressable.

| Method | Path | Notes |
| --- | --- | --- |
| `GET` | `/v1/skills` | Keyset page, newest-edited first. `limit` default 24, max 100. `q` searches name/description/tags. `scope=all\|mine\|team`. `sort=downloads` optional. Counts are over the matching set, not the page. |
| `POST` | `/v1/skills` | Hand-authored. Required-ish `name` (empty becomes `"New skill"`). Default `type` `workflow`. **201**. |
| `POST` | `/v1/skills/generate` | Body `{ "sessionIds": [...], "hint": { name, description, type, tags } }`. Empty `sessionIds` is 400. Missing sessions 404. Nothing usable 422. No LLM configured 500. Org-scoped in-process querier. **201**. |
| `GET` | `/v1/skills/{id}` | Head row. Versions are history only. |
| `PUT` | `/v1/skills/{id}` | Partial head update. Does not publish. |
| `DELETE` | `/v1/skills/{id}` | Creator only: other org members get **403**, not 404. **204**. |
| `GET` | `/v1/skills/{id}/versions` | Full history, newest first, unpaged. |
| `POST` | `/v1/skills/{id}/versions` | Snapshot + advance semver. Head stays on the skill row. **201**. |
| `POST` | `/v1/skills/{id}/duplicate` | Fork; `parentId` set; new version history. **201**. |
| `GET` | `/v1/skills/{id}/skill.md` | Attachment `{slug}.md`. Frontmatter name is the kebab slug. Download counter is best-effort. |
| `GET` | `/v1/sessions/{id}/skills` | Skills generated from that session; unpaged. |

Authorship reads `x-paper-auth-subject`. Generation reuses the search/embedding credential (`SkillLLM*` on the API config); it does not require a separate provider key.

## Admin

Operator routes. They are published in OpenAPI; the process does not add its own auth group.

:::endpoint POST /v1/admin/seed/demo Seed demo corpora
Replays bundled captures through ingest, then derives. Idempotent via raw-turn dedup. Body optional. `overwrite: true` is **400** (`overwrite is no longer supported`).
:::

:::endpoint POST /v1/admin/derive/run Re-derive every org
Rebuilds traces, spans, links, and session rollups from immutable `raw_turns`. Prunes projection rows the current deriver no longer emits. Response `{ "orgs": { "<org>": RederiveReport } }`. Cost scales with the raw layer.
:::

:::endpoint POST /v1/admin/raw-turns/attribution-repair Repair attribution
Append-only correction; does **not** rewrite `raw_turns`. Select **exactly one** of `raw_turn_id` or `paper_proxy_request_id`. Required: `harness_id`, `harness_session_id`, `reason`. `parent_harness_session_id` must be omitted rather than `""`, and cannot equal `harness_session_id`.

| Status | Meaning |
| --- | --- |
| **200** | Repair applied and projections rebuilt. |
| **202** | Correction committed; `projections_pending` names sessions the derive worker will converge. **Do not retry.** |
| **400** | Invalid selector or replacement. |
| **404** | Raw turn not found. |
| **409** | Selector is ambiguous. |

`source_cleanup_pending` can accompany 200 or 202. It is a leftover empty source-session row. Nothing retries that delete.
:::

<RequestExample>

```json
{
  "raw_turn_id": 1842,
  "harness_id": "claude-code",
  "harness_session_id": "sess_abc",
  "thread_id": "",
  "reason": "turn belonged to the parent session"
}
```

</RequestExample>

## MCP

`/v1/mcp` is mounted with `All` but only **POST**, **GET**, and **DELETE** are documented (so generated clients do not get unused verbs).

| Verb | Operation | Behavior |
| --- | --- | --- |
| `POST` | `invokeMcp` | JSON-RPC 2.0 (`initialize`, `tools/list`, `tools/call`). |
| `GET` | `openMcpStream` | `text/event-stream`. |
| `DELETE` | `closeMcpSession` | Session-termination semantics. |

Transport is **stateless**. Cassette tools come from operations marked `x-tapes-mcp` (POST JSON body, cassette-qualified names such as `summary.summarize_session`). The legacy core tool `search` (`query`, optional `top_k` default 5) is registered only while span search is configured in core.

## Cassette proxy

Cassettes share origin, port, metrics, recovery, and compression with core. Core registers `/v1/cassettes` and `/v1/cassettes/{name}/openapi.json` **before** the wildcard proxy.

| Path | Behavior |
| --- | --- |
| `GET /v1/cassettes` | Discovery: `contract_version`, installed `cassettes[]` (name, `route_prefix`, `openapi_path`, `openapi_status`, `manifest_digest`, schema-only `config[]`), and `problems[]` for failed sources. |
| `GET /v1/cassettes/{name}/openapi.json` | Cached republished spec, even if the cassette process is down. `ETag` is the digest of that document. **404** `unknown_cassette`, **503** `spec_unavailable`, **304** on matching `If-None-Match`. |
| `ALL /v1/cassettes/{name}` and `/*` | Reverse proxy. Path prefix is rewritten to the cassette's local prefix; the cassette never sees `/v1/cassettes`. Bodies are buffered (not streamed). Down cassette → **502** `cassette_unavailable`. |

`GET /openapi` compiles core + every currently cached cassette spec per request. Two requests a millisecond apart are byte-identical if the cache did not change. The seal in `api/CONTRACT` does **not** include this merge.

Spec refresh retries every 500ms for the first 15s of startup, then on the configured interval.

## CONTRACT seal rules

`api/CONTRACT` holds one line:

```text
sha256:<64 hex>
```

That value is `CompiledDoc.Fingerprint()` of the **prose-stripped core** document — the same bytes as `tapes dev openapi api --docs-root ''`. `api/openapi_seal_test.go` recompiles with `nil` TypeDocs and compares.

| Change | Moves the seal? |
| --- | --- |
| Path, method, parameter, schema field, status code | yes |
| Inline `oasfiber.Doc` summary/description/tag text | yes (published surface) |
| Go field doc comments | no |
| Mounting or unmounting a cassette at runtime | no (not in the sealed core document) |
| `GET /openapi` merge contents | no |

If the test fails, it prints the fingerprint to write. Bump `api/CONTRACT` in the **same** change that moved the contract. Do not paste a `make contracts` / default `tapes dev openapi` fingerprint: that is the documented document and would make every comment edit a contract event. The suite asserts the two fingerprints differ.

`api/openapi_coverage_test.go` is the other gate: the compiled document must describe the Fiber route table (with the exemptions listed above). Coverage cannot see a rename that breaks generated clients; the seal can.

Reserved OpenAPI component names (`Result`, `Option`, `String`, `Vec`, …) are published as `{Package}{Name}` (for example `SeedResult`) so a Rust progenitor client does not shadow prelude types.

## Constraints and failure modes

| Symptom | Cause / check |
| --- | --- |
| Search **503** | Embedder or span store missing, or embed worker has not initialized the projection. |
| Sessions/skills **501** | Driver is not Postgres (or otherwise lacks the capability). |
| Harness list **400** | `harness_id` without `harness_session_id`, or harness filter combined with cursor/sort/window. |
| Skill generate **422** | Named sessions exist but carried nothing the generator can use. |
| Skill delete **403** | Caller `x-paper-auth-subject` is not the creator. |
| Attribution repair **202** | Correction is recorded; do not POST again. Watch derive. |
| Cassette **502** | Process down; discovery and cached `openapi.json` still work. |
| Empty `/openapi` cassette ops | Spec not fetched yet; wait out the 15s startup retry or check `problems` on `GET /v1/cassettes`. |
| Client built against a stale YAML | Use live `GET /openapi` or re-run `tapes dev openapi`. |

## Next

<CardGroup>
  <Card title="Read API vs ingest" href="/read-vs-ingest">Two ports, two seals, and the trust boundary.</Card>
  <Card title="Ingest API" href="/ingest-api">Private write contract on :8082.</Card>
  <Card title="Search spans" href="/search-spans">Embed worker, GET /v1/search/spans, and tapesctl search.</Card>
  <Card title="Generate skills" href="/generate-skills">Skill store, transcript spine, tapesctl generate/list/sync.</Card>
  <Card title="MCP" href="/mcp">Streamable HTTP at /v1/mcp and x-tapes-mcp tools.</Card>
  <Card title="Cassettes" href="/cassettes">Admission, path rewrite, and operator-owned lifecycle.</Card>
  <Card title="Inspect and export" href="/inspect-and-export">List, browse, optional web UI, JSONL export.</Card>
  <Card title="Contribute" href="/contribute">CONTRACT bumps, make test, tapes vs tapesctl.</Card>
</CardGroup>
