# Tapes Documentation

> Operator and integrator reference for the tapes server: capture, derive, query, search, cassettes, and the private ingest write path. Use this set to install, run, configure, and extend the Go binary in this repository. Client capture and read commands live in tapesctl and are cited only where this server requires them.

## Context Links

- [Agent index](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/llms.txt)
- [Human interactive docs](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750)
- [GitHub repository](https://github.com/papercomputeco/tapes)

## Repository Metadata

- Repository: papercomputeco/tapes

- Generated: 2026-08-18T20:20:03.787Z
- Updated: 2026-08-19T01:07:53.887Z
- Runtime: Grok CLI
- Format: Documentation
- Pages: 23

## Page Index

- 01. [Overview](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/01-overview.md) - What tapes captures, which binary owns the database, default listen ports, and the first docs routes after install.
- 02. [Installation](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/02-installation.md) - Install the tapes server and tapesctl client, bootstrap local PostgreSQL and Ollama, and verify with tapes version and tapes local status.
- 03. [Quickstart](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/03-quickstart.md) - Start tapes serve, point tapesctl at :8081, seed demo data, list sessions, and capture one real agent against :8082.
- 04. [Capture and derive](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/04-capture-and-derive.md) - Append-only raw_turns, the lossy reduction stored beside raw_response, and the idempotent deriver that projects sessions, traces, and spans.
- 05. [Sessions, traces, and spans](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/05-sessions-traces-and-spans.md) - Deterministic derived IDs, session rollups, trace-as-turn, span kinds, and how subagent threads rejoin through transcript anchors.
- 06. [Read API vs ingest](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/06-read-api-vs-ingest.md) - Two sealed contracts, two ports, and the trust boundary: :8081 is the public read surface and :8082 is a private in-cluster write path.
- 07. [Cassettes](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/07-cassettes.md) - cassette/v1alpha1 admission: OpenAPI plus x-tapes-cassette, path rewrite under /v1/cassettes/{name}, and operator-owned process lifecycle.
- 08. [Capture an agent](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/08-capture-an-agent.md) - Point Claude, Codex, pi, or a generic provider client at capture: tapesctl start/capture vs a fixed-port proxy on :8080, always writing ingest on :8082.
- 09. [Search spans](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/09-search-spans.md) - Semantic search over embedded main-conversation LLM spans via GET /v1/search/spans, tapesctl search, and the MCP search tool.
- 10. [Inspect and export](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/10-inspect-and-export.md) - List sessions, browse traces and raw_turns, enable the optional API web UI, and stream GET /v1/sessions/{id}/export as JSONL.
- 11. [Generate skills](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/11-generate-skills.md) - Server skill store and /v1/skills routes, transcript spine used for generation, and how tapesctl generate/list/sync talk to this API.
- 12. [Run a cassette locally](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/12-run-a-cassette-locally.md) - Admit the bundled hello-world cassette with Compose, --cassettes, and tapesctl against the republished /v1/cassettes surface.
- 13. [Configure embeddings](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/13-configure-embeddings.md) - Switch embedding.provider between ollama and openai, set model and dimensions, store keys with tapes auth, and run or disable the embed worker.
- 14. [CLI reference](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/14-cli-reference.md) - tapes commands and flags: init, local, serve, status, auth, config, backfill, raw equivalence, dev, and version. tapesctl is the client, not this binary.
- 15. [Configuration reference](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/15-configuration-reference.md) - Dotdir resolution, flag then TAPES_ then config.toml precedence, dotted keys, defaults, credentials.toml, and the cassettes array.
- 16. [Read API](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/16-read-api.md) - Compiled GET /openapi for :8081: sessions, traces, spans, stats, search, skills, admin, MCP, cassette proxy, and CONTRACT seal rules.
- 17. [Ingest API](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/17-ingest-api.md) - Private write contract on :8082: POST /v1/ingest, POST /v1/ingest/transcript, body cap, JSON error envelope, and 413 reject_oversize.
- 18. [MCP](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/18-mcp.md) - Streamable HTTP MCP at /v1/mcp: cassette tools marked x-tapes-mcp, the legacy search tool, and stateless transport limits.
- 19. [Split the stack](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/19-split-the-stack.md) - Run proxy, api, ingest, derive-worker, and embed-worker as separate processes, including flags, DSNs, and independent failure domains.
- 20. [Gateway capture](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/20-gateway-capture.md) - tapes-extproc Envoy adapter: env config, gRPC recv limits, ingest POST of completed turns, and RawResponseMode for the capture ratchet.
- 21. [Prove the capture ratchet](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/21-prove-the-capture-ratchet.md) - off, dual, and raw reducer modes, plus tapes raw equivalence classes, excluded fields, and the read-only comparison against stored reductions.
- 22. [Troubleshooting](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/22-troubleshooting.md) - Wrong-port capture, missing OpenAI embed keys, ingest 413, attribution-repair 200 vs 202, --wipe data loss, and GOEXPERIMENT=jsonv2 build failures.
- 23. [Contribute](https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/23-contribute.md) - Nix or Make build, make test through Dagger, OpenAPI CONTRACT seals, sqlc generate, PR title labels, and tapes vs tapesctl ownership.

## Source File Index

- `AGENTS.md`
- `api/admin_handlers.go`
- `api/cassetterunner/mcp.go`
- `api/cassettes.go`
- `api/CONTRACT`
- `api/mcp/cassette.go`
- `api/mcp/mcp.go`
- `api/mcp/search.go`
- `api/openapi_routes.go`
- `api/openapi.go`
- `api/search_spans_handler.go`
- `api/sessions_handlers.go`
- `api/skills_handlers.go`
- `api/trace_browse_handlers.go`
- `api/v1_handlers.go`
- `api/web_ui.go`
- `cli/tapes-extproc/main.go`
- `cmd/tapes/auth/auth.go`
- `cmd/tapes/backfill/backfill.go`
- `cmd/tapes/config/config.go`
- `cmd/tapes/config/set.go`
- `cmd/tapes/dev/checkopenapi.go`
- `cmd/tapes/dev/dev.go`
- `cmd/tapes/init/init.go`
- `cmd/tapes/local/local.go`
- `cmd/tapes/raw/equivalence.go`
- `cmd/tapes/serve/api/api.go`
- `cmd/tapes/serve/cassettes.go`
- `cmd/tapes/serve/deriveworker/deriveworker.go`
- `cmd/tapes/serve/embedworker/embedworker.go`
- `cmd/tapes/serve/ingest/ingest.go`
- `cmd/tapes/serve/proxy/proxy.go`
- `cmd/tapes/serve/serve.go`
- `cmd/tapes/serve/stack.go`
- `cmd/tapes/status/status.go`
- `cmd/tapes/tapes.go`
- `cmd/tapes/version/version.go`
- `CONTRIBUTING.md`
- `Dockerfile.extproc`
- `docs/apis.md`
- `docs/architecture.md`
- `docs/cassette-walkthrough.md`
- `docs/cassettes.md`
- `docs/cli.md`
- `docs/configuration.md`
- `docs/data.md`
- `docs/development.md`
- `docs/installation.md`
- `docs/integrations.md`
- `docs/introduction.md`
- `docs/mcp.md`
- `docs/search.md`
- `docs/skills.md`
- `e2e/hurls/ingest/ingest.hurl`
- `extproc/config.go`
- `extproc/dispatcher.go`
- `extproc/processor.go`
- `extproc/rawlane.go`
- `fixtures/thread/README.md`
- `flake.nix`
- `go.mod`
- `ingest/config.go`
- `ingest/CONTRACT`
- `ingest/errorhandler.go`
- `ingest/ingest.go`
- `ingest/openapi.go`
- `ingest/rawreduce.go`
- `ingest/responses.go`
- `install.sh`
- `makefile`
- `migrations/1781049035_raw_turns.up.sql`
- `migrations/1781230000_span_model.up.sql`
- `pkg/capture/reducer.go`
- `pkg/cassette/examples/hello-world/cassette.toml`
- `pkg/cassette/examples/hello-world/compose.yaml`
- `pkg/cassette/examples/hello-world/main.go`
- `pkg/cassette/examples/mcp-tool/main.go`
- `pkg/cassette/v1alpha1/manifest.go`
- `pkg/cassette/v1alpha1/validate.go`
- `pkg/config/cassettes.go`
- `pkg/config/defaults.go`
- `pkg/config/flags.go`
- `pkg/config/types.go`
- `pkg/config/viper.go`
- `pkg/derive/deriver.go`
- `pkg/derive/fold.go`
- `pkg/derive/reconcile.go`
- `pkg/derive/spans.go`
- `pkg/derive/worker/worker.go`
- `pkg/dotdir/manager.go`
- `pkg/embeddings/embedder.go`
- `pkg/embeddings/ollama/ollama.go`
- `pkg/embeddings/openai/openai.go`
- `pkg/embeddings/utils/new.go`
- `pkg/embedworker/worker.go`
- `pkg/rawequiv/rawequiv.go`
- `pkg/rawequiv/report.go`
- `pkg/skill/generator.go`
- `pkg/skill/transcript.go`
- `pkg/skill/types.go`
- `pkg/skill/writer.go`
- `pkg/spanembed/spanembed.go`
- `proxy/proxy.go`
- `README.md`

---

## 01. Overview

> What tapes captures, which binary owns the database, default listen ports, and the first docs routes after install.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/01-overview.md
- Generated: 2026-08-18T20:11:56.368Z

### Source Files

- `README.md`
- `cmd/tapes/tapes.go`
- `cmd/tapes/serve/serve.go`
- `docs/introduction.md`
- `AGENTS.md`
- `go.mod`

---
title: "Overview"
description: "What tapes captures, which binary owns the database, default listen ports, and the first docs routes after install."
---

`tapes` is the Go server for this repository (`github.com/papercomputeco/tapes`, Go 1.26+). It owns PostgreSQL, runs the capture and derive pipeline, and serves the sealed read and ingest contracts. It does not launch agents or read sessions back: that is [`tapesctl`](https://github.com/papercomputeco/tapesctl), a separate client.

## What tapes captures

Capture is append-only. A transparent proxy (or a trusted ingest write) intercepts completed LLM turns and persists them in `raw_turns`. Each turn is stored twice:

| Column | Role |
| --- | --- |
| `raw_response` | Upstream bytes, verbatim |
| `response` | Lossy reduction produced by the capture adapter |

A pure, idempotent **deriver** projects that log into the read model **sessions → traces → spans**. Re-derive reproduces the same projection and prunes derived rows that are no longer present. Derived IDs are deterministic. Content addressing (`merkle.ProjectContent`) runs in memory at derive time for identity and dedup; the persisted merkle `nodes` table is gone and is not a browsing surface.

Reads go through the derived surface: `/v1/sessions`, `/v1/traces`, `/v1/stats`, `/v1/search/spans`, and the original capture at `/v1/sessions/{id}/raw_turns`.

The in-repo proxy understands Anthropic, OpenAI, and Ollama request shapes. Gateway capture uses `tapes-extproc` (Envoy ext_proc), a separate image that POSTs completed turns to ingest and must not depend on the API, the database, or the derive worker being healthy.

## Two binaries

| Binary | Owns | Does not own |
| --- | --- | --- |
| `tapes` | Services, PostgreSQL (with pgvector), operator CLI (`init`, `local`, `serve`, `status`, `auth`, `config`, `backfill`, `raw`, `dev`, `version`) | Agent launch, session list/export/search/skill client commands |
| `tapesctl` | Capture and read against a running server | The database, listen sockets, workers |

Commands that used to live here moved with the client: `tapes start` → `tapesctl start`, `tapes export` → `tapesctl export`, `tapes seed` → `tapesctl seed`, `tapes sessions` → `tapesctl sessions list`, `tapes search` → `tapesctl search`, `tapes skill` → `tapesctl skill`. Do not re-add them to this binary. `tapes chat` is removed. Span search stays server-side (`GET /v1/search/spans` plus the embed worker).

<Warning>
`tapesctl` never guesses a server. Configure the read API once (`tapesctl config set tapes-url http://localhost:8081`). Capture (`start`, `capture`, `sync`) must target ingest on `:8082`. A capture pointed at the read port reports success and stores nothing.
</Warning>

## Default listen ports

Defaults come from `NewDefaultConfig()`:

| Address | Config key | Surface |
| --- | --- | --- |
| `:8080` | `proxy.listen` | Provider-compatible capture proxy. Forwards to `proxy.upstream` (default `http://localhost:11434`) as `proxy.provider` (default `ollama`). |
| `:8081` | `api.listen` | Public read API: health, derived sessions/traces/spans, search, skills, admin, MCP, cassette proxy, compiled `GET /openapi`. |
| `:8082` | `ingest.listen` | Private in-cluster write path: `POST /v1/ingest`, `POST /v1/ingest/transcript`, `GET /ping`. |

Client-side defaults in this binary's config are `client.proxy_target = http://localhost:8080` and `client.api_target = http://localhost:8081`. The client you actually run (`tapesctl`) uses `tapes-url` / `TAPES_URL` / `--tapes-url` instead.

| Port | `tapesctl` commands |
| --- | --- |
| `:8081` | `sessions`, `traces`, `spans`, `search`, `export`, `seed` (`POST /v1/admin/seed/demo` on the API, which replays bundled corpora through ingest + derive) |
| `:8082` | `start`, `capture`, `sync` |

`GET /` on the API is off unless you pass `--api-web-ui` (`api.web_ui`, default `false`). Do not infer production auth, TLS, or exposure from these listen defaults.

## All-in-one runtime

```bash
tapes local up    # Docker: Postgres+pgvector (and Ollama unless a native server is already up)
tapes serve
```

`tapes serve` starts, in one process:

- proxy, read API, and ingest HTTP servers
- in-process derive worker (2s debounce)
- in-process embed worker (10s interval) unless `--embed-spans=false`

Embedding defaults: `embedding.provider = ollama`, `embedding.model = embeddinggemma`, `embedding.dimensions = 768`, `embedding.target = http://localhost:11434`. A down embedding backend disables search; it does not fail the stack. Split the same roles with `tapes serve proxy|api|ingest|derive-worker|embed-worker`.

```mermaid
flowchart LR
  subgraph client["tapesctl"]
    startCmd["start / capture"]
    readCmd["sessions / search / export / seed"]
  end

  subgraph serve["tapes serve"]
    proxy[":8080 proxy"]
    ingest[":8082 ingest"]
    api[":8081 read API"]
    derive["derive worker"]
    embed["embed worker"]
  end

  subgraph store["PostgreSQL"]
    raw["raw_turns"]
    derived["sessions / traces / spans"]
    vec["pgvector span embeddings"]
  end

  agent["agent or provider client"] --> proxy
  proxy --> upstream["upstream LLM"]
  proxy --> raw
  startCmd --> ingest
  ingest --> raw
  raw --> derive --> derived
  derived --> embed --> vec
  derived --> api
  vec --> api
  readCmd --> api
```

## First docs routes after install

Install both binaries, then follow these pages in order. They are the first routes after this overview.

<Steps>
<Step title="Install and bootstrap">
Install `tapes` and `tapesctl`, run `tapes local up`, and confirm with `tapes version` and `tapes local status`. See [Installation](/installation).
</Step>
<Step title="Serve, seed, capture">
Run `tapes serve`, point `tapesctl` at `http://localhost:8081`, seed demo data, list sessions, then capture a real agent against `:8082`. See [Quickstart](/quickstart).
</Step>
</Steps>

<CodeGroup>

```bash title="Install the server"
curl -fsSL https://download.tapes.dev/install | bash
tapes version
```

```bash title="Install the client"
curl -sSfL https://download.tapes.dev/tapesctl/install | bash
tapesctl version
```

</CodeGroup>

After `tapes serve`, a live check of the read API:

```bash
curl http://localhost:8081/ping
tapes status
```

`tapesctl seed` is idempotent: raw-turn dedup is `(org_id, request_id)`, transcript rows hash their content, and derive upserts the same projection. Seeded sessions are tagged project `demo`.

## Operator surface on this binary

| Command | Role |
| --- | --- |
| `tapes init [--preset openai\|anthropic\|ollama]` | Create `.tapes/` |
| `tapes local up\|status\|down` | Local Postgres and Ollama. `down --wipe` deletes captured data. |
| `tapes serve` | Full local stack |
| `tapes status` | Config, upstream, API reachability, capture summary |
| `tapes auth` | Provider keys in `.tapes/credentials.toml` (embedding / skill generation — not agent capture) |
| `tapes config get\|set\|list` | Scalar settings. Precedence: flag, then `TAPES_…`, then `config.toml`, then defaults. |
| `tapes raw equivalence` | Compare stored `raw_response` re-reductions against stored `response` |
| `tapes version` | `Version`, `Sha`, `Built at` |

## What this repo does not do

- Host chat. Capture and derive only.
- Ship the client CLI. `tapesctl` is a different repository.
- Persist a merkle browsing graph.
- Replace the console for ROI / session browsing. The deck TUI is removed; enable `--api-web-ui` only for the minimal API HTML UI.

## Next

<CardGroup>
<Card title="Installation" href="/installation">
Install tapes and tapesctl, bootstrap local PostgreSQL and Ollama, verify with tapes version and tapes local status.
</Card>
<Card title="Quickstart" href="/quickstart">
Start tapes serve, point tapesctl at :8081, seed demo data, then capture one agent against :8082.
</Card>
<Card title="Capture and derive" href="/capture-and-derive">
Append-only raw_turns, the lossy reduction beside raw_response, and the idempotent deriver.
</Card>
<Card title="Read API vs ingest" href="/read-vs-ingest">
Two sealed contracts and the :8081 / :8082 trust boundary.
</Card>
</CardGroup>

---

## 02. Installation

> Install the tapes server and tapesctl client, bootstrap local PostgreSQL and Ollama, and verify with tapes version and tapes local status.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/02-installation.md
- Generated: 2026-08-18T20:12:31.854Z

### Source Files

- `install.sh`
- `docs/installation.md`
- `cmd/tapes/local/local.go`
- `cmd/tapes/init/init.go`
- `cmd/tapes/version/version.go`
- `makefile`

---
title: "Installation"
description: "Install the tapes server and tapesctl client, bootstrap local PostgreSQL and Ollama, and verify with tapes version and tapes local status."
---

Tapes ships as two binaries. `tapes` is the server: it owns PostgreSQL, runs proxy / read API / ingest / workers, and writes `.tapes/` configuration. `tapesctl` is a separate client (repository `papercomputeco/tapesctl`) that captures sessions and reads them back. Release installs come from `https://download.tapes.dev`; local dependencies are started with `tapes local up` (Docker required).

## Prerequisites

| Need | Why |
| --- | --- |
| `curl`, `uname`, writable `/tmp` | `install.sh` download path |
| Linux or macOS (`darwin`) | Script maps `uname -s`; other OS values exit `Unsupported OS` |
| `amd64` or `arm64` | `x86_64` → `amd64`; `aarch64`/`arm64` → `arm64`; other arches exit |
| `sudo` (default install) | Moves the binary to `/usr/local/bin` |
| Docker | Required for `tapes local`; missing `docker` on `PATH` fails with `docker is required for 'tapes local'` |

Ollama is optional on the host. Native Ollama is preferred when it is already serving; otherwise the bootstrap starts `tapes-local-ollama`. PostgreSQL is not optional for a working server.

## Install the binaries

<Steps>
<Step title="Install the server">

```bash
curl -fsSL https://download.tapes.dev/install | bash
tapes version
```

The script downloads `https://download.tapes.dev/$TAPES_VERSION/$OS/$ARCH/tapes` (`TAPES_VERSION` defaults to `latest`), `chmod +x`, and `sudo mv` into `TAPES_INSTALL_DIR` (default `/usr/local/bin`). It then runs `tapes version` as the smoke test.

</Step>
<Step title="Install the client">

```bash
curl -sSfL https://download.tapes.dev/tapesctl/install | bash
tapesctl version
```

`tapesctl` is not built from this repository. Do not re-add client commands (`start`, `export`, `seed`, `sessions`, `search`, `skill`) to `tapes`.

</Step>
<Step title="Confirm PATH">

Each `version` call must print and exit 0. `tapes version` writes:

```text
Version: <tag or git describe>
Sha: <commit>
Built at: <UTC timestamp>
```

Those fields are linker-stamped (`pkg/utils.Version`, `Sha`, `Buildtime`). A missing command means the install directory is not on `PATH`.

</Step>
</Steps>

<ParamField body="TAPES_VERSION" type="string">
Release selector used by `install.sh`. Default `latest`. Example: `TAPES_VERSION=v0.17.0`.
</ParamField>

<ParamField body="TAPES_INSTALL_DIR" type="string">
Destination directory for the `tapes` binary. Default `/usr/local/bin`. Must be writable via `sudo` (or already owned if you change it).
</ParamField>

<Tip>
Soft update checks (not `tapes version`) GET `https://download.tapes.dev/latest/version`. Disable with `--disable-update-check` or `update.disabled`. Dev / nightly / `git describe` infix builds skip the prompt.
</Tip>

## Bootstrap PostgreSQL and Ollama

```bash
tapes local up
tapes local status
```

Bare `tapes local` is the same as `tapes local up`. Docker must be available.

### What starts

| Resource | Default |
| --- | --- |
| Docker network | `tapes-local` |
| Postgres container | `tapes-local-postgres` |
| Postgres image | `public.ecr.aws/g4e5l3z3/papercomputeco/postgres:17.7-pgduckdb-1.1.1` |
| Host port | `5432` → container `5432` |
| Role / password / database | `tapes` / `tapes` / `tapes` |
| DSN written to config | `postgres://tapes:tapes@localhost:5432/tapes?sslmode=disable` |
| Data directory | `<active .tapes>/postgres` mounted at `/tapes-postgres`, `PGDATA=/tapes-postgres/data` |
| Ollama container (when used) | `tapes-local-ollama` from `ollama/ollama:latest` |
| Ollama host port | `11434` → container `11434` |
| Ollama model volume | `~/.ollama` → `/root/.ollama` |
| Embedding model pulled | `embeddinggemma:latest` |
| Model name stored in config | `embeddinggemma` |

Postgres readiness is `pg_isready` for up to 30s. Containerized Ollama is polled with `ollama list` for up to 60s, then `ollama pull embeddinggemma:latest`.

### Ollama placement

`tapes local up` does not always start a container. The plan is:

| Condition | Result |
| --- | --- |
| `--docker-ollama` or `tapes-local-ollama` already exists | Use the Docker container |
| Native server answers `GET http://127.0.0.1:<port>/api/version` with 200 | Reuse native Ollama; pull the model with host `ollama pull` |
| `ollama` is on `PATH` but the port is silent | Print a warning: start Ollama yourself (`ollama serve` or the app) and `ollama pull embeddinggemma:latest`. No container is started |
| No native install | Pull and start `tapes-local-ollama` |

Native is preferred because containerized Ollama does not get GPU access on macOS, and a published container on the same port can shadow a loopback-only native server.

```bash
tapes local up --docker-ollama
```

### Config written on success

On success the command persists settings into the active `.tapes/config.toml` so later `tapes serve` needs no DSN or embedding flags:

| Key | Value |
| --- | --- |
| `storage.postgres_dsn` | Local DSN above |
| `vector_store.target` | Same DSN |
| `proxy.upstream` | `http://127.0.0.1:11434` or `http://localhost:11434` |
| `embedding.provider` | `ollama` |
| `embedding.target` | Same Ollama URL |
| `embedding.model` | `embeddinggemma` |

If the write fails, the command prints `tapes config set embedding.target …` and `tapes serve --postgres "<dsn>"` instead of `tapes serve`.

<ParamField body="--postgres-port" type="int">
Host port for Postgres. Default `5432`. Persistent on `tapes local` and subcommands.
</ParamField>

<ParamField body="--ollama-port" type="int">
Host port for Ollama. Default `11434`.
</ParamField>

<ParamField body="--postgres-image" type="string">
Override the pinned Postgres image (must still provide pgvector + pg_duckdb for the product path).
</ParamField>

<ParamField body="--ollama-image" type="string">
Override the Ollama image. Default `ollama/ollama:latest`.
</ParamField>

<ParamField body="--docker-ollama" type="boolean">
Force the Ollama container even when a native install is serving.
</ParamField>

<ParamField body="--config-dir" type="string">
Root command flag. Overrides `.tapes/` resolution for data + config (`--config-dir` → `./.tapes` → `~/.tapes`).
</ParamField>

### Status, stop, wipe

```bash
tapes local status
tapes local down
tapes local down --wipe
```

`tapes local status` inspects `tapes-local-postgres` and `tapes-local-ollama` via `docker ps -a`. If the Ollama container is missing but a native server answers `/api/version`, it prints `ollama (native)` and the loopback URL. Otherwise a missing name is `not created`.

`tapes local down` `docker rm -f` both containers. Data under `<.tapes>/postgres` stays. `--wipe` then `RemoveAll`s that directory and permanently deletes locally captured sessions.

<Warning>
`--wipe` is irreversible for the local Postgres volume. Ollama models in `~/.ollama` are not deleted.
</Warning>

## Optional project-local config

```bash
tapes init
tapes init --preset ollama
tapes init --preset openai
tapes init --preset anthropic
tapes init --preset https://example.com/config.toml
```

`tapes init` creates `./.tapes/config.toml` (mode `0600`) in the current working directory. That directory then wins over `~/.tapes/` for server commands. Presets: `openai` (upstream `https://api.openai.com`), `anthropic` (`https://api.anthropic.com`), `ollama` (`http://localhost:11434`). An `http://` or `https://` preset is fetched and parsed as TOML.

`tapes local up` without a pre-existing directory creates `~/.tapes` and stores Postgres data there. A cwd `.tapes/` created by `init` is used instead.

`tapesctl` does **not** read project-local `.tapes/`. It uses `~/.tapes/config.toml` and the single key `tapes-url`.

## Verify

<RequestExample>
```bash
tapes version
tapes local status
```
</RequestExample>

Expected after a successful Docker bootstrap:

- `tapes version` prints `Version`, `Sha`, `Built at`
- `tapes local status` shows `tapes-local-postgres` as Up
- Ollama is either `tapes-local-ollama` Up or `ollama (native) http://127.0.0.1:11434`

`tapes status` is a different command: it reports config dir, provider → upstream, whether `storage.postgres_dsn` is set, `client.api_target` (default `http://localhost:8081`), and probes the read API (`/v1/stats`, 3s timeout). Before `tapes serve`, API is expected to be unreachable.

After you start the stack (`tapes serve`), defaults are proxy `:8080`, read API `:8081`, private ingest `:8082`, embedding worker on. `GET http://localhost:8081/ping` returns JSON `"pong"`. Point the client at the **read** API once:

```bash
tapesctl config set tapes-url http://localhost:8081
```

Capture commands must target ingest `:8082` explicitly. Continue on [Quickstart](/quickstart).

## Source builds

Use this only when you are developing this repository. Go **1.26+** and `GOEXPERIMENT=jsonv2` are required (`make build-local` sets both; Nix `flake.nix` exports `GOEXPERIMENT=jsonv2`).

<CodeGroup>
```bash title="Make"
make build-local
./build/tapes version
./build/tapes local up
make install   # install -m 0755 ./build/tapes $(go env GOBIN)/tapes
```

```bash title="Nix flake"
nix develop
make help
make build-local
```
</CodeGroup>

`make install` copies via `install(1)` (new inode) so an in-place overwrite does not invalidate a running macOS Mach-O signature. Contributor tests go through `make test` (Dagger + pinned Postgres). See [Contribute](/contribute).

## Compose and container image

`docker-compose.yaml` starts the same Postgres image, `ollama/ollama`, and a `Dockerfile`-built `tapes` that runs `serve` against in-network `ollama:11434`. Published host ports are **8080 and 8081 only**; ingest `:8082` is not mapped in that file.

The image is a static `CGO_ENABLED=0` `./cli/tapes` binary (`EXPOSE 8080`, entrypoint `/app/tapes`). `tapes-extproc` is a separate image and is not part of local install.

## Embeddings without Ollama

PostgreSQL is still required. To use OpenAI embeddings instead of the local Ollama model:

```bash
tapes auth openai
tapes config set embedding.provider openai
```

`OPENAI_API_KEY` is accepted instead of `tapes auth openai`. Model and `embedding.dimensions` (default `768` for `embeddinggemma`) must match the provider. Store keys in `.tapes/credentials.toml`, not `config.toml`. Details: [Configure embeddings](/configure-embeddings).

## Common failures

| Symptom | Cause / fix |
| --- | --- |
| `Unsupported OS` / `Unsupported architecture` | `install.sh` only maps linux/darwin and amd64/arm64 |
| `tapes: command not found` after install | `TAPES_INSTALL_DIR` not on `PATH`, or `sudo mv` failed |
| `docker is required for 'tapes local'` | Install Docker and ensure `docker` is on `PATH` |
| Native Ollama warning, no container | Binary is installed but `/api/version` is down — start it and pull `embeddinggemma:latest`, or pass `--docker-ollama` |
| Postgres not ready within 30s | Port `5432` already bound; try `--postgres-port` |
| Ollama container not ready within 60s | Image pull / daemon issue; `tapes local status` and Docker logs |
| `tapes status` API unreachable | Expected until `tapes serve`; not a substitute for `tapes local status` |
| Source build / merkle JSON errors | Build without `GOEXPERIMENT=jsonv2` — use `make build-local` |
| `--wipe` emptied sessions | Intended: the `<.tapes>/postgres` directory was deleted |

More operational failures (wrong capture port, ingest 413, missing OpenAI keys): [Troubleshooting](/troubleshooting).

## Next

<CardGroup>
<Card title="Quickstart" href="/quickstart">
Start `tapes serve`, point `tapesctl` at `:8081`, seed demo data, and capture against `:8082`.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Dotdir resolution, flag / `TAPES_` / `config.toml` precedence, and defaults.
</Card>
<Card title="CLI reference" href="/cli-reference">
`tapes` commands: `init`, `local`, `serve`, `status`, `auth`, `config`, `version`.
</Card>
<Card title="Configure embeddings" href="/configure-embeddings">
Switch `embedding.provider` between `ollama` and `openai`, and run or disable the embed worker.
</Card>
<Card title="Contribute" href="/contribute">
Nix or Make source builds, `GOEXPERIMENT=jsonv2`, and `make test` through Dagger.
</Card>
</CardGroup>

---

## 03. Quickstart

> Start tapes serve, point tapesctl at :8081, seed demo data, list sessions, and capture one real agent against :8082.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/03-quickstart.md
- Generated: 2026-08-18T20:12:38.325Z

### Source Files

- `docs/introduction.md`
- `cmd/tapes/serve/serve.go`
- `cmd/tapes/serve/stack.go`
- `cmd/tapes/local/local.go`
- `cmd/tapes/status/status.go`
- `README.md`

---
title: "Quickstart"
description: "Start tapes serve, point tapesctl at :8081, seed demo data, list sessions, and capture one real agent against :8082."
---

`tapes serve` is the all-in-one local runtime: capture proxy on `:8080`, public read API on `:8081`, private ingest API on `:8082`, an in-process derive worker (2s debounce), and — unless `--embed-spans=false` — an in-process embed worker (10s interval). `tapes` owns PostgreSQL. `tapesctl` is the client: it never guesses a host, and it talks to two different ports for read versus capture.

## Prerequisites

Install both binaries and bootstrap local PostgreSQL (pgvector) plus Ollama as described in [Installation](/installation). Docker is required for `tapes local up`. Confirm:

```bash
tapes version
tapesctl version
tapes local up
tapes local status
```

`tapes local up` writes `storage.postgres_dsn`, `vector_store.target`, `proxy.upstream`, and `embedding.{provider,target,model}` into the active `.tapes/config.toml` (creating `~/.tapes` when no project dir exists). Defaults after bootstrap: DSN `postgres://tapes:tapes@localhost:5432/tapes?sslmode=disable`, embedding provider `ollama`, model `embeddinggemma` (768 dims), Ollama at `http://localhost:11434`. Native Ollama is reused when it is already serving; `--docker-ollama` forces the container.

<Note>
`tapes local up` pulls the embedding model, not a chat model. A completion model is only needed if you send traffic through the `:8080` proxy to Ollama.
</Note>

## Default ports

| Listen | Process | Client use |
| --- | --- | --- |
| `:8080` | Capture proxy | Optional fixed-port `ANTHROPIC_BASE_URL` / generic provider base URL |
| `:8081` | Read API | `tapesctl seed`, `sessions`, `export`, `search` |
| `:8082` | Private ingest API | `tapesctl start`, `capture`, `sync` |

A capture pointed at `:8081` reports success and stores nothing. Configure the read URL once; pass `--tapes-url http://localhost:8082` on every capture command.

```mermaid
flowchart LR
  subgraph Client
    tapesctl
    Agent[agent process]
  end
  subgraph Serve["tapes serve"]
    P["proxy :8080"]
    R["read API :8081"]
    I["ingest :8082"]
    D["derive worker"]
    E["embed worker"]
  end
  PG[("PostgreSQL + pgvector")]
  OL["Ollama embeddings"]
  tapesctl -->|"seed / list / search / export"| R
  tapesctl -->|"start / capture / sync"| I
  Agent -->|"optional fixed-port"| P
  P --> PG
  I --> PG
  D --> PG
  E --> PG
  E --> OL
  R --> PG
```

## Start the stack

<Steps>
<Step title="Run tapes serve">

Leave this in a foreground terminal. `SIGINT` / `SIGTERM` stops the in-process workers and tears the HTTP servers down.

```bash
tapes serve
```

Defaults: `proxy.provider=ollama`, `proxy.upstream=http://localhost:11434`, `proxy.project` from the current Git repo name when unset, `api.web_ui=false`, span embedding on.

Useful overrides:

```bash
tapes serve --api-web-ui
tapes serve --embed-spans=false
tapes serve --provider anthropic --upstream https://api.anthropic.com
```

</Step>
<Step title="Verify the read API">

```bash
curl http://localhost:8081/ping
tapes status
```

`GET /ping` returns the JSON string `"pong"`. `tapes status` probes `client.api_target` (default `http://localhost:8081`) with a 3s `/v1/stats` call and prints config dir, provider → upstream, whether a Postgres DSN is set, and `N sessions · M turns · $… captured`. Unreachable API output tells you to run `tapes local up` then `tapes serve`.

</Step>
<Step title="Point tapesctl at :8081">

```bash
tapesctl config set tapes-url http://localhost:8081
```

That writes `tapes-url` in `~/.tapes/config.toml` only. A project `.tapes/` is invisible to the client. Precedence: `--tapes-url` > `TAPES_URL` > configured value. With none of the three, a command that needs a server fails instead of guessing.

</Step>
<Step title="Seed demo sessions">

```bash
tapesctl seed
```

The client calls the read API `POST /v1/admin/seed/demo`. The server replays two bundled corpora (`corpus-cb9a87e5`, `corpus-9fec0da7`) through an in-process ingest (`POST /v1/ingest` then `POST /v1/ingest/transcript`), tags them `project=demo`, and derives each session **synchronously**. Seeded rows are indistinguishable from live capture.

Re-run is a no-op: raw-turn dedup (`org_id` + `request_id`) and transcript content-hash absorb replays. `overwrite` is rejected (`400`).

<ResponseExample>

```json
{
  "sessions": 2,
  "raw_turns": 0,
  "raw_turns_inserted": 0,
  "raw_turns_deduped": 0
}
```

</ResponseExample>

`raw_turns` is the replayed corpus row count. After the first seed, later runs report insertions as `0` and increment `raw_turns_deduped`.

</Step>
<Step title="List sessions">

```bash
tapesctl sessions list
```

Equivalent HTTP:

```bash
curl http://localhost:8081/v1/sessions
```

Use the Tapes session UUID from this list for `sessions get`, `export`, and traces — not the harness session id `tapesctl start` prints on exit.

```bash
tapesctl sessions get <session-id>
tapesctl sessions traces <session-id>
tapesctl sessions raw-turns <session-id>
tapesctl export <session-id> -o session.jsonl
```

`--detail traces` on export is turn headers only; the default is the full span tree (`GET /v1/sessions/{id}/export`).

</Step>
<Step title="Optional: search seeded spans">

Embedding is a separate loop. After seed, wait for the in-process embed worker (~10s) then:

```bash
tapesctl search "explain the retry logic"
```

Empty results exit 0 (`No results found.`). Disable embedding only if you do not need search: `tapes serve --embed-spans=false`. Embed setup failures log a warning and leave search unavailable; they do not fail `tapes serve`.

</Step>
<Step title="Capture one real agent on :8082">

Clear demo data only if you want an empty store (`--wipe` deletes the local Postgres data directory under `.tapes/postgres`):

```bash
# stop tapes serve first
tapes local down --wipe && tapes local up
tapes serve
```

Launch a supported harness against **ingest**, not the read API:

<Tabs>
<Tab title="Claude">

```bash
tapesctl start claude --tapes-url http://localhost:8082
tapesctl start claude --tapes-url http://localhost:8082 -- --worktree
```

`start` binds a just-in-time loopback capture proxy, sets `ANTHROPIC_BASE_URL`, launches `claude`, and POSTs completed turns to `:8082`. On exit it prints the harness session id — find the Tapes id with `tapesctl sessions list`.

Claude transcripts on disk carry subagent structure the wire path cannot. Sweep them after a session that ran without capture:

```bash
tapesctl sync --tapes-url http://localhost:8082
```

Default window is the last seven days; `--since-days 0` sweeps everything. Re-push is safe (server dedup).

</Tab>
<Tab title="Codex CLI">

```bash
tapesctl start codex --tapes-url http://localhost:8082
```

</Tab>
<Tab title="pi">

```bash
tapesctl plugin install pi
tapesctl start pi --tapes-url http://localhost:8082
tapesctl start pi --tapes-url http://localhost:8082 --schema openai
```

`start pi` refuses to run without the plugin. `--schema` is valid only on `pi`; it is an error on `claude` or `codex`.

</Tab>
<Tab title="Codex desktop">

```bash
tapesctl plugin install codex-app
tapesctl capture codex-app --tapes-url http://localhost:8082
```

The app launches itself. Install first — `capture` without the plugin fails. `capture` reports sessions seen, not turn counts.

</Tab>
</Tabs>

The agent brings its own provider credentials. `tapes auth` is for server-side embedding and skill generation, not capture.

After a live ingest, session rows exist immediately; traces and spans appear after the derive debounce (2s in-process). Then:

```bash
tapesctl sessions list --tapes-url http://localhost:8081
```

</Step>
</Steps>

## OpenAI embeddings (optional)

Local Postgres is still required. Switch the embedder and store a key (or export `OPENAI_API_KEY`):

```bash
tapes auth openai
tapes config set embedding.provider openai
tapes serve
```

`tapes serve` constructs the embedder at startup. OpenAI with no key fails immediately: `OPENAI_API_KEY is required for openai embeddings`. Model and dimensions must match the provider output (`text-embedding-3-large` is the OpenAI embedder default).

## Fixed-port proxy instead of `tapesctl start`

For a long-lived proxy on `:8080` (not the just-in-time client proxy):

```bash
tapes serve --provider anthropic --upstream https://api.anthropic.com
ANTHROPIC_BASE_URL=http://localhost:8080 claude
```

Default `tapes serve` forwards Ollama-compatible traffic to `http://localhost:11434`:

```bash
curl http://localhost:8080/api/chat \
  -H 'Content-Type: application/json' \
  -d '{"model":"qwen3-coder:30b","messages":[{"role":"user","content":"hello"}],"stream":false}'
```

Pull that chat model yourself (`ollama pull qwen3-coder:30b` or `docker exec -it tapes-local-ollama ollama pull …`).

## Stop and reset

| Command | Effect |
| --- | --- |
| `Ctrl-C` on `tapes serve` | Stops proxy, read API, ingest, derive, embed |
| `tapes local down` | Removes `tapes-local-postgres` and `tapes-local-ollama`; keeps Postgres files |
| `tapes local down --wipe` | Also deletes the `.tapes/postgres` data dir — all captured sessions |

<Warning>
`--wipe` is permanent. Recreate storage with `tapes local up` before `tapes serve` again.
</Warning>

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| Capture “succeeds”, `sessions list` empty | `start` / `capture` / `sync` aimed at `:8081` | Use `--tapes-url http://localhost:8082` |
| `tapesctl` fails with no host | No `--tapes-url`, `TAPES_URL`, or `tapes-url` | `tapesctl config set tapes-url http://localhost:8081` |
| `tapes status` API unreachable | Serve or Postgres down | `tapes local status`, then `tapes serve` |
| `docker is required for 'tapes local'` | Docker missing from `PATH` | Install Docker; `tapes local` shells out to it |
| Native Ollama installed but idle | `tapes local up` will not start it | `ollama serve` and `ollama pull embeddinggemma:latest` |
| `OPENAI_API_KEY is required…` at serve | `embedding.provider=openai` with no key | `tapes auth openai` or `OPENAI_API_KEY` |
| Search empty after seed | Embed worker not finished, or `--embed-spans=false` | Wait ~10s; `curl http://localhost:11434/api/tags` |
| `sessions get` 404 on printed id | Used harness session id | Take the UUID from `tapesctl sessions list` |
| Seed `400` overwrite | Old client sent `overwrite: true` | Omit it; seed is idempotent |
| Live session listed, no traces yet | Derive debounce | Wait 2s on all-in-one `tapes serve` |

<AccordionGroup>
<Accordion title="What tapes serve starts versus split processes">

Bare `tapes serve` is the local convenience process. The same binaries can run separately (`tapes serve proxy`, `api`, `ingest`, `derive-worker`, `embed-worker`) with their own DSNs and failure domains. Split deployment is not required for this quickstart.

</Accordion>
<Accordion title="Seed HTTP contract">

:::endpoint POST /v1/admin/seed/demo Replay bundled demo corpora through ingest and derive
Optional JSON body: `{ "overwrite": false }`. `overwrite: true` → `400`. Success → `seed.Result`. Missing raw-turn driver → `501`.
:::

</Accordion>
</AccordionGroup>

## Next

<CardGroup>
<Card title="Capture an agent" href="/capture-an-agent">
Harness matrix, JIT vs fixed-port `:8080`, and ingest on `:8082`.
</Card>
<Card title="Read API vs ingest" href="/read-vs-ingest">
Why `:8081` and `:8082` are different contracts and a trust boundary.
</Card>
<Card title="Capture and derive" href="/capture-and-derive">
`raw_turns` append, reduction beside `raw_response`, idempotent projection.
</Card>
<Card title="Inspect and export" href="/inspect-and-export">
Session list, traces, raw_turns, optional web UI, JSONL export.
</Card>
<Card title="Search spans" href="/search-spans">
`GET /v1/search/spans` after the embed worker runs.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Wrong-port capture, missing embed keys, ingest 413, `--wipe`.
</Card>
</CardGroup>

---

## 04. Capture and derive

> Append-only raw_turns, the lossy reduction stored beside raw_response, and the idempotent deriver that projects sessions, traces, and spans.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/04-capture-and-derive.md
- Generated: 2026-08-18T20:13:16.179Z

### Source Files

- `docs/architecture.md`
- `pkg/derive/deriver.go`
- `pkg/derive/worker/worker.go`
- `ingest/ingest.go`
- `proxy/proxy.go`
- `migrations/1781049035_raw_turns.up.sql`
- `pkg/capture/reducer.go`

---
title: "Capture and derive"
description: "Append-only raw_turns, the lossy reduction stored beside raw_response, and the idempotent deriver that projects sessions, traces, and spans."
---

Capture writes completed LLM turns into PostgreSQL `raw_turns` and never rewrites them. A derive worker then re-projects each dirty harness session into the read model of sessions, traces, and spans. `tapes serve` runs the proxy (`:8080`), private ingest (`:8082`), read API (`:8081`), and an in-process derive worker together; production splits `tapes serve ingest` and `tapes serve derive-worker` so capture and projection fail independently.

<Note>
`tapes` owns the database and this pipeline. `tapesctl` is the client: it points agents at capture and reads the derived surface. Do not mix `:8081` (read) with `:8082` (ingest).
</Note>

## Pipeline

```mermaid
flowchart TB
  subgraph Capture["Capture"]
    Agent["agent / tapesctl / gateway"]
    Proxy["proxy :8080"]
    Extproc["tapes-extproc"]
    Agent --> Proxy
    Agent --> Extproc
  end

  subgraph Write["Private write"]
    Ingest["ingest :8082<br/>POST /v1/ingest<br/>POST /v1/ingest/transcript"]
    Pool["proxy worker pool"]
    Proxy --> Pool
    Extproc --> Ingest
  end

  subgraph Store["PostgreSQL"]
    Raw["raw_turns<br/>request + response + raw_response"]
    Queue["derive_queue"]
    Sess["sessions row"]
    Proj["traces / spans / links"]
    Pool --> Raw
    Ingest --> Raw
    Pool --> Sess
    Ingest --> Sess
    Raw --> Queue
  end

  subgraph Derive["Derive worker"]
    Worker["poll / debounce / lock"]
    Deriver["Deriver.AddTurn + Finish"]
    Reconcile["ReconcileTranscripts"]
    Emit["EmitSpans"]
    Worker --> Deriver --> Reconcile --> Emit --> Proj
    Queue --> Worker
  end

  subgraph Read["Read API :8081"]
    API["/v1/sessions / traces / spans / raw_turns"]
    Proj --> API
    Raw --> API
  end
```

| Surface | Default | Role |
| --- | --- | --- |
| `proxy.listen` | `:8080` | Transparent provider proxy. Forwards upstream, then appends a wire `raw_turns` row through the in-process worker pool. |
| `ingest.listen` | `:8082` | Trusted write contract. `POST /v1/ingest` and `POST /v1/ingest/transcript`. |
| `api.listen` | `:8081` | Derived read surface. Does not accept capture. |
| Derive worker | in-process on `tapes serve`; else `tapes serve derive-worker` | Polls `derive_queue`, re-derives one session at a time. |

Embedding is a separate worker (`tapes serve embed-worker`). It is not a derive step; a down embed backend must not stall projection.

## Capture paths

Two writers land the same append-only table.

| Path | How it writes | What it stores |
| --- | --- | --- |
| Local proxy (`tapes serve` / `tapes serve proxy`) | After the upstream response, `proxy/worker` calls `PutRawTurn` on the Postgres driver. No HTTP hop to ingest. | `source=wire`, verbatim `raw_request`, reduced `response`. The local persist path does not attach `raw_response`. |
| Gateway / `tapesctl` / extproc | `POST /v1/ingest` on `:8082` | Same wire row, plus optional `raw_response` bytes and `raw_response_encoding`. |
| Harness transcript | `POST /v1/ingest/transcript` | `source=transcript`. `records` land in `raw_request`. The deriver uses these rows as the causal/fork skeleton, not as extra LLM calls. |

Supported wire providers are `anthropic`, `openai`, and `ollama`. Capture adapters reduce streaming (SSE / NDJSON) or one-shot JSON into `llm.ChatResponse` with the shared `pkg/capture` reducers. Server-side reducers exist today for Anthropic and OpenAI Responses; Ollama has no server-side reducer, so it cannot move to raw-only ingest until one exists.

A POST that is not a chat/completion body is still forwarded by the proxy; only completed chat turns enqueue capture.

## Append-only `raw_turns`

`raw_turns` is the immutable capture log. The deriver is a pure function of these rows: a classifier or span-shape change is a re-derive, not a re-capture.

| Column | Meaning |
| --- | --- |
| `source` | `wire` or `transcript`. |
| `provider`, `agent_name` | Wire identity. |
| `harness_id`, `harness_session_id` | Session key the worker derives as one unit. |
| `request_id` | Dedup key when non-empty. Retried POSTs of the same attempt are a no-op (`ON CONFLICT` on `(org_id, request_id)`). Empty `request_id` is plain append. |
| `raw_request` | Verbatim request JSON (or transcript `records`). Stored as JSONB without re-marshaling through parsed structs, so unknown fields survive. |
| `response` | Reduced `llm.ChatResponse`. This is what the deriver reads on a healthy turn. |
| `raw_response` | Upstream response bytes, stored under the original `Content-Encoding`. BYTEA, never JSON-scrubbed. |
| `raw_response_encoding` | `identity`, `gzip`, or empty (identity). |
| `raw_response_dropped` | `true` when verbatim bytes existed and were not stored. Distinguishes “never captured” from “had bytes, chose not to keep them”. |
| `meta` | Adapter metadata (`request_id`, `thread_id`, `content_type`, `ts_request`, `captured_at`, `elapsed_seconds`, …). |
| `session_envelope` | Session-tracking envelope, stored verbatim. |
| `received_at` | Ingest receive time. Chronology fallback when capture-side stamps are missing. |

JSON payloads are sanitized only for sequences Postgres JSONB cannot store. `raw_response` is not sanitized.

<Warning>
This deployment is single-tenant. Ingest clears `session.org_id` on the write path so a client cannot store rows the read side will never surface.
</Warning>

### Dedup and dirty marks

`PutRawTurn` appends the row and, when `harness_session_id` is set, upserts `derive_queue` in the same transaction. A retried POST that hits the unique index still marks the session dirty: a redundant mark only costs one idempotent derive.

Transcript rows use a content-addressed `request_id`:

```text
transcript:{harness_session_id}:{agentKey}:{sha256(records)[:8]}
```

`agentKey` is `agent_id` or `main`. Re-uploading unchanged content is a no-op (`deduped: true`). A grown file is a new row. The deriver keeps the latest version per `(session, agent, lifecycle kind)` so an `interacted` re-entry never supersedes a spawn anchor.

### Limits

| Limit | Value | On overflow |
| --- | --- | --- |
| Ingest HTTP body (`MaxIngestBodyBytes`) | `32 MiB` request + base64 of `8 MiB` raw response + `4 MiB` reserve ≈ `46.67 MiB` | `413` JSON `{"error":"..."}`, metric `tapes_ingest_writes_total{status="reject_oversize"}`. Body is not parsed. |
| Stored `raw_response` (`MaxRawResponseBytes`) | `8 MiB` | Bytes dropped, row still written, `raw_response_dropped=true`. Reduced `response` is kept. |
| Producer withheld bytes (`raw_response_withheld`) | flag on the envelope | Same dropped marker when no bytes arrived. If bytes are present, bytes win. |
| Proxy request buffer | same `MaxIngestBodyBytes` | `413`, connection closed on declared oversize. |

A raw-layer persist failure on `POST /v1/ingest` is logged and does not fail the HTTP write: the handler still runs session ingest. Treat a missing raw row after `202` as a storage outage to inspect, not as a client retry of a successful envelope.

## Lossy reduction beside `raw_response`

Reduction turns upstream bytes into one canonical `llm.ChatResponse`. It is lossy by design: fields the reducer does not model are gone from `response`. Two adapters that reduce the same traffic differently would produce different rows. The raw column exists so the reduction is auditable, not authoritative.

An ingest envelope may send:

| Payload | What ingest stores |
| --- | --- |
| Reduction only (`off`) | `response` only. Historical adapter shape. |
| Reduction + bytes (`dual`) | Both. Ingest keeps the adapter’s reduction; bytes sit beside it for `tapes raw equivalence`. |
| Bytes only (`raw`) | Ingest reduces with `ReduceStoredRawTurn` **before** the raw write, so the row carries both halves. |

`reduceRawOnly` is a no-op when a reduction is already present. An adapter that consumed the live stream may have seen framing the stored bytes no longer show; re-reducing that turn would lose information.

A failed raw-only reduction does **not** reject ingest. The bytes still land. Recovery is on the derive read path: `GetRawTurn` selects `raw_response` only when the stored reduction has no role or content blocks, and `recoverReduction` re-runs the Anthropic / OpenAI reducer before `Deriver.AddTurn`. A later reducer fix therefore recovers those turns on the next derive. Recovery is best-effort: one unreducible turn is logged and skipped; it does not fail the session.

`created_at` and `usage.total_duration_ns` are stamped at reduce time. Under `raw`, ingest restores them from `meta.captured_at` / `meta.ts_request` and `meta.elapsed_seconds`. A window can be byte-equivalent and still lose duration if those meta fields were empty.

See [Prove the capture ratchet](/capture-ratchet) for `off` / `dual` / `raw` and `tapes raw equivalence`.

## Ingest HTTP

:::endpoint POST /v1/ingest Append one completed wire turn
Appends `source=wire`. Persists the raw envelope **before** provider parse, so a `422` still leaves a row a later parser can re-derive.

**Success:** `202` `{"status":"accepted"}`. Capture is acknowledged; the projection is asynchronous.

**Errors:** `400` invalid envelope / session (`ErrEnvelope`); `422` unknown provider or unparseable request / empty reduction (`ErrUnprocessable`); `413` oversize; `502` worker saturation or storage (`ErrDownstream`). Same JSON envelope: `{"error":"..."}`.
:::

:::endpoint POST /v1/ingest/transcript Append one harness transcript or spawn-anchor row
Requires Postgres. Requires `session.harness_session_id`.

`agent_id` + `tool_use_id` carry the subagent fork edge. `kind` empty/`started` is spawn evidence; `kind=interacted` (`send_message`, `followup_task`) is stored and counted, then ignored by derivation.

**Success:** `202` with `status`, `deduped`, `records`, `agent_id`.

**Errors:** `400` envelope; `422` unstorable JSONB content; `501` if the raw-turn layer is unavailable; `502` storage.
:::

Successful ingest also UPSERTs the `sessions` row when a session envelope is present. The span writer skips a harness key with no sessions row: raw turns without a resolved session do not appear on the read API until that row exists. A bare proxied call with no envelope gets a synthetic `harness_session_id` from the in-memory merkle root prefix so turns still group.

## Derive queue and worker

`derive_queue` is keyed by `(org_id, harness_id, harness_session_id)`, not by the sessions UUID. Transcript ingest can write a raw row before a sessions row exists; dirty state is queue state.

| Column | Role |
| --- | --- |
| `dirtied_at` | Last mark. Debounce waits for this to go quiet. |
| `first_dirtied_at` | Survives re-marks. Bounds lag for a session that never settles. |

Worker defaults (`pkg/derive/worker`):

| Setting | Flag / key | Default | `tapes serve` in-process |
| --- | --- | --- | --- |
| Poll | `--poll-interval` / `derive_worker.poll_interval` | `5s` | `5s` |
| Debounce | `--debounce` / `derive_worker.debounce` | `20s` | `2s` |
| Max lag | `--max-derive-lag` / `derive_worker.max_derive_lag` | `45s` | `45s` |
| Sweep | `--sweep-interval` / `derive_worker.sweep_interval` | `1h` (+ once at startup) | `1h` |
| Sweep window | `--sweep-window` / `derive_worker.sweep_window` | `24h` (negative = all history) | `24h` |
| Concurrency | — | one session at a time | same |
| Drain | — | `30s` after SIGTERM/SIGINT | same |

A session derives when it has settled (`dirtied_at` older than debounce) **or** when `first_dirtied_at` is older than max lag. Continuously streaming sessions re-mark on every capture and never settle; the lag bound is what projects them.

Work is at-least-once:

1. Take a per-session Postgres advisory lock (`TryDeriveSessionLock`). Another replica skips (`locked`).
2. Re-read the queue row under the lock.
3. `RederiveSession`.
4. `ClearDeriveDirty` only if `dirtied_at` is unchanged. A turn that landed mid-derive leaves the row queued.

A derive error stays on that session and does not stall the page. Poll failures back off exponentially up to `30s`. `--wait-for-db` retries startup; otherwise an unreachable DSN fails fast. `--metrics-listen` serves `/metrics`, `/healthz`, `/readyz` (the ready probe runs `DeriveQueueStats`, the same query the loop needs).

Run the standalone worker with its own memory budget. A full derive once OOM-killed a 256Mi API pod; do not embed this loop in the API process. The worker applies a cgroup-derived soft `GOMEMLIMIT` unless one is already set.

```bash
tapes serve derive-worker --postgres "$TAPES_STORAGE_POSTGRES_DSN"
```

## Deriver

`RederiveSession` is the unit of work: one harness session, streamed in capture order.

1. Index raw rows. Wire rows sort by `CapturedAt`. Transcript rows keep the latest version per `(agent, kind)`.
2. `GetRawTurn` applies the latest attribution correction overlay. It does not mutate `raw_turns`.
3. Recover an empty reduction from `raw_response` when needed.
4. `Deriver.AddTurn` for each wire row: parse request, decode `response`, build the in-memory merkle chain, classify the call, dedup nodes by content hash.
5. `Finish` runs attach passes (verdicts, web summaries, plan-name-gen).
6. `ReconcileTranscripts` joins transcript spawn anchors onto wire chains.
7. `EmitSpans` writes traces, spans, and links. Session rollups (title, model usage, tasks, kind counts, status) fold in the same transaction.
8. Upsert the projection and prune rows the new set no longer contains, scoped to that session.

Re-running unchanged raw input upserts the same deterministic IDs and prunes zero rows. IDs are functions of wire identity (`request_id`, `tool_use_id`, `thread_id`), not of wall-clock derive time.

The persisted `nodes` table is gone. Merkle content addressing (`merkle.ProjectContent`) exists only in memory for identity and dedup. Memory tracks unique content, not the sum of every turn’s re-sent history.

A turn whose reduced response has no `role` or content blocks is `raw_only`: counted, not failed, and produces no chain. Parse failures are sampled (cap 20) on `RederiveReport.parse_failures`.

`CapturedAt` precedence for span start: `captured_at` rewound by `elapsed_seconds` when both are valid; else `ts_request`; else `captured_at` alone; else `received_at`. Elapsed values above seven days are treated as corrupt.

Call kinds are an open catalog (`main`, `offshoot:permission-check:stage1|stage2`, `offshoot:title-gen`, `offshoot:compaction`, …). Unknown kinds stay `unknown` rather than being silently bucketed. Span kinds and subagent rejoin live on [Sessions, traces, and spans](/sessions-traces-spans).

<AccordionGroup>
<Accordion title="RederiveReport fields">
<ResponseField name="raw_turns" type="int">Rows fed to this pass.</ResponseField>
<ResponseField name="parsed_turns" type="int">Rows that produced a chain.</ResponseField>
<ResponseField name="raw_only_turns" type="int">Rows skipped because the reduction has no role or content.</ResponseField>
<ResponseField name="parse_failures" type="string[]">Sampled parse errors (`raw_turn id=… request_id=…`).</ResponseField>
<ResponseField name="call_kinds" type="object">Counts by classified call kind, including `unknown`.</ResponseField>
<ResponseField name="reconcile" type="object">Transcript join stats, including `codex_threads_unanchored` and `codex_interacted_rows`.</ResponseField>
</Accordion>
</AccordionGroup>

## Manual re-derive

:::endpoint POST /v1/admin/derive/run Rebuild every persisted session from raw_turns
Same pass as `tapes dev rederive`. Enumerates sessions from the read model (so an emptied repair source is still pruned), then `RederiveSessionLocked` one session at a time.

Use after a deriver or classifier change. For a full historical sweep on the worker, set `--sweep-window` negative.
:::

`POST /v1/admin/raw-turns/attribution-repair` records an append-only correction and synchronously re-derives the previous and effective sessions. `200` finished; `202` means the correction committed but projection rebuild is pending — the worker converges; do not retry the repair.

`tapesctl seed` (or `POST /v1/admin/seed/demo`) replays bundled corpora through the normal raw write + derive path. Overwrite is rejected; seeding is idempotent against the raw layer.

## Verify

<Steps>
<Step title="Capture one turn">
Point an agent at ingest (`tapesctl start … --tapes-url http://localhost:8082`) or send traffic through `:8080`. Confirm `202` from ingest, or a growing `raw_turns` count in `tapes status`.
</Step>
<Step title="Wait for derive">
Local `tapes serve` uses a 2s debounce. Standalone workers wait 20s unless lag hits 45s. Then list sessions on the read API:

```bash
tapesctl sessions list --tapes-url http://localhost:8081
tapesctl sessions raw-turns <session-id>
tapesctl sessions traces <session-id>
```
</Step>
<Step title="Inspect the split">
`GET /v1/sessions/{id}/raw_turns` is the capture log. Trace and span routes are the projection. Session IDs on the read API are UUIDs, not merkle hashes, and not the harness session id `tapesctl start` prints.
</Step>
</Steps>

## Failure modes

| Symptom | Likely cause | What to do |
| --- | --- | --- |
| Session never appears | Client talked to `:8081` instead of `:8082`, or no `sessions` row for the harness key | Capture against ingest; check `tapes status` and ingest logs. |
| Raw row exists, no traces | Worker not running, debounce/lag not elapsed, or derive error on that session | `tapes serve derive-worker`; check worker logs / `derive_queue`; `POST /v1/admin/derive/run`. |
| `413` on ingest | Envelope over `MaxIngestBodyBytes` | Shrink the payload or drop `raw_response` and set `raw_response_withheld`. |
| `raw_response_dropped` | Bytes over 8 MiB, or producer withheld | Reduced `response` should still project; verbatim recovery is gone for that turn. |
| Empty projection after raw-only ingest | No reducer for the provider (Ollama), or reduce failed and bytes missing | Confirm `provider` and that `raw_response` is present; fix reducer and re-derive. |
| Subagent not nested under the spawn tool | Missing transcript / spawn-anchor upload | Upload `POST /v1/ingest/transcript`. Unanchored Codex threads parent to the trace root; `codex_threads_unanchored` increments. |
| `202` on attribution-repair | Correction committed; sync derive did not finish | Do not retry. Wait for the worker. |
| Worker OOM / API OOM | Derive sharing the API pod | Run `tapes serve derive-worker` with its own memory limit. |

## Next

<CardGroup>
<Card title="Sessions, traces, and spans" href="/sessions-traces-spans">
Deterministic IDs, span kinds, and how transcript anchors rejoin subagent threads.
</Card>
<Card title="Read API vs ingest" href="/read-vs-ingest">
Why `:8081` and `:8082` are separate sealed contracts.
</Card>
<Card title="Ingest API" href="/ingest-api">
`POST /v1/ingest` fields, body cap, and the JSON error envelope.
</Card>
<Card title="Prove the capture ratchet" href="/capture-ratchet">
`off` / `dual` / `raw` and `tapes raw equivalence`.
</Card>
<Card title="Split the stack" href="/split-the-stack">
Run proxy, ingest, derive-worker, and embed-worker as separate processes.
</Card>
<Card title="Gateway capture" href="/gateway-capture">
`tapes-extproc` Envoy adapter and `RawResponseMode`.
</Card>
</CardGroup>

---

## 05. Sessions, traces, and spans

> Deterministic derived IDs, session rollups, trace-as-turn, span kinds, and how subagent threads rejoin through transcript anchors.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/05-sessions-traces-and-spans.md
- Generated: 2026-08-18T20:13:01.672Z

### Source Files

- `pkg/derive/spans.go`
- `pkg/derive/reconcile.go`
- `pkg/derive/fold.go`
- `api/sessions_handlers.go`
- `migrations/1781230000_span_model.up.sql`
- `fixtures/thread/README.md`
- `docs/architecture.md`

---
title: "Sessions, traces, and spans"
description: "Deterministic derived IDs, session rollups, trace-as-turn, span kinds, and how subagent threads rejoin through transcript anchors."
---

The deriver projects the append-only `raw_turns` log into a prune-stable read model: one **session** per harness run, one **trace** per user-visible turn, and **spans** for the work inside that turn. Ingest writes session identity only. `EmitSpans` (after `Finish` and `ReconcileTranscripts`) mints traces, spans, links, and session rollups as a pure function of the raw layer. Re-derive upserts the same rows in place and deletes projection rows that the new pass no longer emits.

<Info>
Session primary keys are ingest-minted UUIDs, stable on the natural key `(org_id, harness_id, harness_session_id)`. Trace and span IDs are deterministic text minted from wire identity (`request_id`, `tool_use_id`, `thread_id`). They are not UUIDs and not content hashes.
</Info>

```mermaid
flowchart TB
  subgraph ingest ["Ingest :8082"]
    raw["raw_turns append-only"]
    sess["sessions identity UPSERT"]
  end
  subgraph derive ["derive-worker"]
    rec["ReconcileTranscripts"]
    emit["EmitSpans"]
    fold["session rollups"]
  end
  subgraph store ["Projection 2026-06-15"]
    turns["span_turns_20260615"]
    spans["spans_20260615"]
    links["span_links_20260615"]
  end
  subgraph read ["Read API :8081"]
    list["GET /v1/sessions"]
    traces["GET /v1/sessions/{id}/traces"]
    one["GET /v1/traces/{trace_id}"]
  end
  raw --> rec
  sess --> rec
  rec --> emit
  emit --> fold
  emit --> turns
  emit --> spans
  emit --> links
  fold --> sess
  sess --> list
  turns --> traces
  spans --> traces
  links --> traces
  turns --> one
  spans --> one
  links --> one
```

## The derived tree

| Layer | Meaning | Identity |
| --- | --- | --- |
| Session | One harness or agent run | UUID `sessions.id`; natural key `(org_id, harness_id, harness_session_id)` |
| Trace | One user-visible turn | `trc_` + `callIdentity` |
| Span | One unit of work inside that turn | Prefix + wire id (see below) |
| Span link | Causality the parent tree cannot express | `(from_trace, from_span, to_trace, to_span, from_io, to_io)` |

Containment is `parent_span_id`. Links are a separate graph (`emits`, `feeds`, `rejoin`, `verdict`, `compaction-seam`). Subagent work does **not** open a second session: every thread-labeled span is emitted into the single host trace that owns the spawn.

```text
trace  trc_<request_id>                 one user turn
└── agent  agent_main_<request_id>      name = main
    ├── event  evt_<hash[:16]>          injected context (optional)
    ├── llm    llm_<request_id>         call_kind = main, thread_id = ""
    │   └── tool  <tool_use_id>         emits → tool input
    │       ├── llm  llm_<shadow_id>    offshoot:permission-check:*  (verdict)
    │       └── agent  agent_<thread>   name = subagent  (rejoin → tool)
    │           ├── llm  llm_<child>    call_kind = main, thread_id set
    │           └── tool  <child_use>   nested spawn (Codex / Claude)
    └── llm  llm_<compaction>           offshoot:compaction → next trace
```

## Session identity

Ingest UPSERTs `sessions` on `(org_id, harness_id, harness_session_id)` and mints `id` app-side (no Postgres default). A retry of the same natural key returns the same UUID. `org_id` is unconstrained in this repo; there is no `orgs` table.

<ParamField body="harness_id" type="string" required>
Harness namespace. Empty or `"unknown"` forces a synthetic `harness_session_id`.
</ParamField>
<ParamField body="harness_session_id" type="string" required>
Harness-native session id. Empty (or missing envelope) synthesizes the first 16 hex chars of the conversation-root Merkle hash. The root is stable across turns because later calls re-send history.
</ParamField>
<ParamField body="parent_session_id" type="uuid">
Harness fork parent, resolved inside the **child's** `harness_id`. Placeholder-inserted if the parent turn has not landed. This is not the subagent-thread join.
</ParamField>
<ParamField body="auth_subject" type="string">
Gateway-stamped JWT subject captured at ingest. List filter only; it grants nothing.
</ParamField>

`DELETE /v1/sessions/{id}` removes the session and derived traces/spans (child sessions cascade). `raw_turns` is left intact.

## Deterministic derived IDs

`callIdentity` is the suffix for traces and LLM/agent-main spans:

| Condition | Suffix |
| --- | --- |
| Non-empty wire `request_id` | `request_id` unchanged |
| Empty `request_id` (legal; disables capture dedup) | `{response_node_hash[:16]}_{raw_turn_id}` |

Empty `request_id` folds in store-assigned `raw_turn_id` so two distinct calls cannot collide and overwrite on upsert. Rows **with** a `request_id` keep the historical byte-identical id.

| Kind | Span ID |
| --- | --- |
| Main agent | `agent_main_` + `callIdentity` |
| Subagent | `agent_` + `thread_id` |
| LLM | `llm_` + `callIdentity` |
| Tool | provider `tool_use_id` |
| Event | `evt_` + node hash `[:16]` |
| Trace | `trc_` + `callIdentity` of the opening call |

Primary keys: `(org_id, trace_id)` on turns; `(org_id, trace_id, span_id)` on spans. Tool keys are scoped `harness_id|harness_session_id|tool_use_id` so two sessions cannot collide on a provider-assigned id. Subagents share the parent `SessionKey`; `thread_id` is not part of that key.

`writeSpanSet` upserts the emit set, then prunes turns/spans/links for covered sessions that are no longer in the keep-set. Unchanged raw rewrites in place and prune removes zero.

The served projection generation is `schema: "2026-06-15"` (`span_turns_20260615`, `spans_20260615`, `span_links_20260615`).

## Session rollups

`GET /v1/sessions` splits capture identity from deriver-owned `rollup`. Rollup fields are empty/`unknown`/zero until the session's first derive.

<ResponseField name="id" type="string">
Ingest-minted session UUID. This is the id `tapesctl sessions get` takes, not the harness session id `tapesctl start` prints.
</ResponseField>
<ResponseField name="display_title" type="string">
Server-resolved label, never empty: `display_name` → folded `rollup.title` → preview (skipped if it looks like JSON) → harness `name` → 12-char `harness_session_id` slice → session id.
</ResponseField>
<ResponseField name="display_name" type="string">
User rename via `PATCH /v1/sessions/{id}`. Writes `sessions.display_name` only; ingest never touches it. Null or empty after trim clears it. Max 200 characters after trim. Absent field is 400.
</ResponseField>
<ResponseField name="name" type="string">
Harness slug, or folded title as fallback when no name was captured. Ingest re-sends this every turn. Render `display_title`, not `name`.
</ResponseField>
<ResponseField name="live" type="boolean">
`ended_at` is null and `last_seen_at` is within 5 minutes. Not gated on `rollup.status` — an interactive session folds `completed` after every `end_turn` while still open.
</ResponseField>
<ResponseField name="rollup" type="object">
`status`, `title`, `preview`, `turn_count`, dominant `model`, cost-ordered `model_usage`, `kind_counts`, `tasks`, and `usage` (`input_tokens`, `output_tokens`, `cost_usd`).
</ResponseField>

### Title

`offshoot:title-gen` responses fold `{"title": "…"}` onto `sessions.derived_title` (latest wins, truncated at 255 UTF-8 bytes). Title-gen is a fold, not a tree fact.

### Status

`FoldSessionStatus` runs at emit time from tool spans plus the last `call_kind=main` LLM span with `thread_id == ""`. Ingest no longer writes status; it stays `unknown` until first derive.

| Status | When |
| --- | --- |
| `failed` | Unrecovered terminal error (`length` / `max_tokens` / `content_filter` / `*error*` stop, or a non-assistant leaf with a tool error); or `tool_error_count * 2 > tool_result_count` |
| `completed` | Git commit/push anywhere (after rule 1) **or** assistant leaf with `stop` / `end_turn` / `end-turn` / `eos` / `tool_use` / `tool_use_response` |
| `abandoned` | Non-assistant leaf, no terminal error |
| `unknown` | No terminal spine span, or unrecognized stop reason |

A mid-session tool error that the model then answers is recovered, not failed. Git activity outranks a high error rate.

### Model usage, tasks, kind counts

- **`model_usage`**: every LLM span, subagent models included, priced at derive time, sorted cost-desc then model name. Share is spend, not call count.
- **`model`**: dominant conversation-spine model (cost lead).
- **`tasks`**: `TaskCreate` / `TaskUpdate` replay in `StartedAt` then `seq` order (not lexicographic `trace_id`).
- **`kind_counts`**: per-`call_kind` span tallies.

A covered session always writes these folds, including empty `[]` / `{}`. Omitting a cleared fold would leave stale JSONB after a re-derive.

## Traces as turns

A new trace opens when a conversation-spine call (`KindMain`, `thread_id == ""`) carries a **fresh genuine prompt**: a first-captured user node with no `tool_result` blocks, after the last fresh assistant node. Re-sent history shares earlier content hashes and does not reopen a trace. `/exit` resume and `/model` switch re-hash recent turns; `lastFreshAssistantIdx` gates prompt, delta input, and event emission so the resume trace keeps only the new turn.

<ResponseField name="user_prompt" type="string">
Always present. Empty means a synthetic opener, not a missing field.
</ResponseField>
<ResponseField name="response_preview" type="string">
Text of the last spine `call_kind=main` LLM span with empty `thread_id`, truncated to 280 runes. Subagent and shadow calls never supply the turn answer.
</ResponseField>
<ResponseField name="synthetic" type="string">
`post-compaction` (continuation after a compaction LLM) or `shadow-opener` (shadow call before any spine call). Absent for genuine prompts.
</ResponseField>
<ResponseField name="source" type="string">
Promoted from `raw_turns.source`: `wire` or `transcript`. Today every served trace is `wire`. Transcript rows reconcile fork edges; they do not form a trace by themselves.
</ResponseField>
<ResponseField name="usage" type="object">
Tokens and `cost_usd` over **all** LLM spans, shadow included. Cache read/creation tokens are on the total only.
</ResponseField>
<ResponseField name="main_usage" type="object">
`call_kind=main` tokens across every thread (main agent + subagents). Shadow spend is `usage − main_usage`.
</ResponseField>
<ResponseField name="tool_calls" type="integer">
Tool-span count, folded at derive so `/v1/stats` does not scan `spans`.
</ResponseField>

Readers sort spans by `seq`, not `started_at`. Parallel tool batches share one timestamp; `seq` freezes walk order (block order).

## Span kinds and call kinds

Kinds (`CHECK (kind IN ('agent', 'step', 'llm', 'tool', 'event'))`):

| `kind` | Name / role |
| --- | --- |
| `agent` | `main` root, or `subagent` for a thread |
| `llm` | One captured API call. `name` is the model (fallback `"llm"`) |
| `tool` | One `tool_use` / `server_tool_use`. `name` is the tool; Codex `exec` / `exec_command` / `shell` with a command input renders as `Bash` |
| `event` | Injected context (`injected:*`) |
| `step` | Reserved, unused |

`call_kind` is the §2g taxonomy on LLM and event spans (`ClassifyCall`):

| `call_kind` | Role |
| --- | --- |
| `main` | Conversation spine |
| `offshoot:permission-check:stage1` / `stage2` | Security monitor |
| `offshoot:title-gen` | Session title fold |
| `offshoot:plan-name-gen` | Plan name |
| `offshoot:suggestion` | Typeahead (`[SUGGESTION MODE` prefix) |
| `offshoot:web-summary` | Fetched-page / search summary |
| `offshoot:probe` | `max_tokens=1`, no tools |
| `offshoot:compaction` | Context summary (main thread only) |
| `injected:mcp-instructions`, `injected:skills-list`, `injected:mode-banner`, `injected:claude-md`, `injected:system-insert` | Injected / mid-spine system inserts |
| `unknown` | No cataloged tell — surfaced, never silently bucketed |

A subagent call classified as compaction is treated as `main` (`#27`). Compaction is a main-thread session event.

Payloads are **delta-only**: LLM `input` is content first captured on that call (no re-sent history, no `tool_result`); tool results live only on the tool span `output`. First result wins. `raw_turn_id` is set on LLM spans (0 on assembled tool/agent spans). Permission-check spans carry a typed `verdict` extracted at derive time.

## Links

| `kind` | Edge |
| --- | --- |
| `emits` | LLM output → tool input |
| `feeds` | Tool output → later LLM input |
| `rejoin` | Subagent `agent` output → spawning tool output |
| `verdict` | Shadow LLM output → judged tool |
| `compaction-seam` | Compaction LLM → next trace's first LLM (cross-trace) |

Same-trace links sit on the turn. Cross-trace links sit on `SpanSet.Links` and on the composite `GET /v1/sessions/{id}/traces` top-level `links` array.

## Subagent threads

`meta.thread_id` is harness-native. Capture observes the harness's own provider headers; it does not strip them. The language-neutral contract is `fixtures/thread/` (sealed `DIGEST`); the header spellings live in `tapes-harnesses` `envelope::thread_id`.

| Harness | Rule |
| --- | --- |
| Claude Code | Non-empty `x-claude-code-agent-id` maps verbatim. Absent = root (`""`) |
| Codex | Both `thread-id` and `session-id` required. Equal pair = root (`""`). Divergent pair = `thread-id`. Lone member = no thread id |
| Precedence | Claude list is tried before the Codex pair |

Root-guard: a non-empty thread id on a root turn misroutes `terminalMainSpan` / `responsePreview` (they require `thread_id == ""`) and degrades derived status.

### Placement

`threadCall` creates one `agent` span named `subagent` (`agent_<thread_id>`), parents it to the spawning tool span, nests the thread's LLM and tool spans under it, and adds a `rejoin` link. The whole thread conversation collapses under the **first** spawn — `followup_task` / `send_message` do not open another agent span.

Codex children share the **root** `harness_session_id`. Only the root appears in the sessions list; child usage folds into the root.

Nested threads resolve to a fixed point: a grandchild waits until its launcher has emitted the spawn tool span. Missing or ambiguous anchors parent the agent span to the **trace root** (never a guessed tool).

### Transcript anchors

The spawn join is not on the LLM wire. It arrives as transcript-source rows on `POST /v1/ingest/transcript`.

**Claude.** Per-agent transcript `meta.json` names `agent_id` + spawning `tool_use_id` (`Task`). Reconcile prefers identity (`chain.thread_id == file.agent_id`); content-block overlap is the fallback for pre-thread-id captures. The fork `ParentToolUseID` is stamped down the whole main chain.

**Codex.** Parent rollouts carry `sub_agent_activity` joining `spawn_agent` `call_id` to child `thread_id`. The uploader ships one transcript row per spawned child, keyed to the **root** session id:

- `agent_id` = child thread id
- `tool_use_id` = `spawn_agent` call id (also the tool span id)
- `records` = exactly one `kind:"started"` rollout line
- `agent_type` / `description` become `subagent_type` / `description` on the spawn tool input (console-facing; wire `spawn_agent` args stay `{task_name, fork_turns, message}`)

Codex anchors **per call**, not per chain root: `fork_turns:"all"` grafts onto the parent spine; `fork_turns:"none"` siblings share a deduped root. A node stamp would miss or collide.

Degrade ladder:

1. Exact `agent_id == thread_id`
2. Unique `agent_path` ↔ `spawn_agent` `task_name` (ambiguous reuse refuses to guess)
3. Unanchored → trace-root parent; counted in `ReconcileStats.codex_threads_unanchored`

`kind:"interacted"` rows (`followup_task`, `send_message`) are inert: excluded from every join, counted only in `codex_interacted_rows`.

Independent readers of the same header contract: `extproc/headers`, `proxy/header`, `pkg/backfill` `threadIDFromHeaders`. A rule change must land in `fixtures/thread/cases/` and `DIGEST`.

## Read API

Session ids on path/query must be UUIDs (400 otherwise). List default `limit` 50, max 200.

| Method | Path | Returns |
| --- | --- | --- |
| `GET` | `/v1/sessions` | Paged identity + `rollup`. Default sort `last_active` desc |
| `GET` | `/v1/sessions/{id}` | Session only — no conversation |
| `GET` | `/v1/sessions/{id}/traces` | Composite: session, traces with spans, session-scoped links. `?payload=full\|preview` |
| `GET` | `/v1/traces?session_id=` | Turn headers, no span payloads |
| `GET` | `/v1/traces/{trace_id}` | One turn + spans + touching links |
| `GET` | `/v1/traces/{trace_id}/spans/{span_id}` | Full payload drill-in |
| `GET` | `/v1/sessions/{id}/raw_turns` | Wire-log headers (`source`, sizes, `request_id`) |
| `PATCH` | `/v1/sessions/{id}` | `display_name` only |
| `DELETE` | `/v1/sessions/{id}` | 204; derived subtree gone, `raw_turns` kept |

`payload=preview` truncates strings at 512 runes and sets `payload` so the console can drill in. Anything other than `preview` is full.

Harness filter: `harness_session_id` alone matches across harnesses (at most one row per harness). `harness_id` alone is 400. Combined with `cursor` / `sort` / `direction` / `since` / `until` is 400. `limit` is ignored on this path.

Client commands live in `tapesctl` (`sessions list|get|traces|raw-turns`). The id they take is `sessions.id`.

Semantic search is span-only over embedded **main-conversation LLM** spans (`GET /v1/search/spans`). It does not search session objects.

## Failure modes

| Symptom | Cause |
| --- | --- |
| Session listed, `rollup.status` is `unknown`, empty usage | Identity UPSERT succeeded; derive has not folded yet |
| Subagent appears as a sibling of `main`, not under a tool | Missing or ambiguous spawn anchor (`codex_threads_unanchored`) |
| Session status wrong / answer preview from a child | Root Codex turn kept `thread-id` because the equal-pair guard failed |
| Duplicate resume traces / replayed reminders | Resume re-hash without the last-fresh-assistant gate (fixed in emit) |
| Two LLM calls collapsed into one span | Empty `request_id` on both rows **and** identical 16-char hash prefix — should not happen after the `raw_turn_id` suffix |
| Stale tasks / kind_counts after a rebuild | Fixed by writing empty folds for every covered session |
| `501` on session/trace routes | Driver is not Postgres (`sessionsReader` / `spanModelReader`) |

## Next

<CardGroup>
  <Card title="Capture and derive" href="/capture-and-derive">
    Append-only raw_turns, reduction beside raw_response, and the idempotent deriver.
  </Card>
  <Card title="Inspect and export" href="/inspect-and-export">
    List sessions, browse traces and raw_turns, optional API web UI, JSONL export.
  </Card>
  <Card title="Read API" href="/read-api">
    Compiled GET /openapi for :8081, including sessions, traces, and spans.
  </Card>
  <Card title="Search spans" href="/search-spans">
    Semantic search over embedded main-conversation LLM spans.
  </Card>
  <Card title="Ingest API" href="/ingest-api">
    POST /v1/ingest and POST /v1/ingest/transcript, including spawn-anchor rows.
  </Card>
</CardGroup>

---

## 06. Read API vs ingest

> Two sealed contracts, two ports, and the trust boundary: :8081 is the public read surface and :8082 is a private in-cluster write path.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/06-read-api-vs-ingest.md
- Generated: 2026-08-18T20:12:46.638Z

### Source Files

- `ingest/openapi.go`
- `api/openapi_routes.go`
- `api/CONTRACT`
- `ingest/CONTRACT`
- `docs/apis.md`
- `cmd/tapes/serve/stack.go`

---
title: "Read API vs ingest"
description: "Two sealed contracts, two ports, and the trust boundary: :8081 is the public read surface and :8082 is a private in-cluster write path."
---

`tapes serve` starts two HTTP servers with two compiled OpenAPI documents. The read API (`Tapes API`, default `:8081`) is the client and operator query surface. The ingest API (`Tapes Ingest API`, default `:8082`) is the private write path every capture adapter POSTs completed turns and harness transcripts through. They share a PostgreSQL driver and nothing else: different Fiber apps, different listen addresses, different trust models.

A third listener, the capture proxy on `:8080`, is not either contract. Provider clients send LLM traffic there. Inspection, search, export, seed, skills, and MCP go to `:8081`. Capture writes go to `:8082`.

```mermaid
flowchart LR
  subgraph clients [Callers]
    readCli["tapesctl sessions / search / export / seed"]
    capCli["tapesctl start / capture / sync"]
    adapters["tapes-extproc, paperd, backfill"]
    llmClient["Provider clients"]
  end

  subgraph proxyPort ["proxy :8080"]
    proxyPaths["Provider-compatible paths"]
  end

  subgraph readPort ["read API :8081"]
    readOpenapi["GET /openapi"]
    readRoutes["sessions, traces, search, skills, admin, MCP, cassettes"]
  end

  subgraph ingestPort ["ingest API :8082"]
    ingestOpenapi["GET /openapi"]
    ingestWrite["POST /v1/ingest"]
    ingestTx["POST /v1/ingest/transcript"]
  end

  subgraph pg [PostgreSQL]
    rawTurns["raw_turns"]
    derived["sessions / traces / spans"]
  end

  readCli --> readPort
  capCli --> ingestWrite
  adapters --> ingestWrite
  adapters --> ingestTx
  llmClient --> proxyPaths
  proxyPaths --> ingestWrite
  ingestWrite --> rawTurns
  ingestTx --> rawTurns
  rawTurns --> derived
  derived --> readRoutes
```

## Two ports, two jobs

| Listener | Config key | Default | Process | Job |
| --- | --- | --- | --- | --- |
| Proxy | `proxy.listen` | `:8080` | `tapes serve` / `tapes serve proxy` | Provider-compatible capture |
| Read API | `api.listen` | `:8081` | `tapes serve` / `tapes serve api` | Query, export, search, skills, MCP, cassette proxy |
| Ingest | `ingest.listen` | `:8082` | `tapes serve` / `tapes serve ingest` | Append `raw_turns` |

`tapesctl` keeps one server URL (`tapes-url` in `~/.tapes/config.toml`). Point that at the read API. Override it on capture commands.

```bash
tapesctl config set tapes-url http://localhost:8081

tapesctl sessions list
tapesctl search "retry logic"
tapesctl seed

tapesctl start claude --tapes-url http://localhost:8082
tapesctl capture codex-app --tapes-url http://localhost:8082
tapesctl sync --tapes-url http://localhost:8082
```

Precedence on the client is `--tapes-url`, then `TAPES_URL`, then the configured value. With none of the three, commands that need a server fail instead of guessing a host.

<Warning>
A capture pointed at `:8081` reports success and stores nothing. `start`, `capture`, and `sync` must address ingest.
</Warning>

Standalone sidecar (gateway / Envoy capture, no local proxy):

```bash
tapes serve ingest --postgres "$TAPES_STORAGE_POSTGRES_DSN"
```

`--listen` / `-l` on that subcommand binds `ingest.listen`. Embedding flags are accepted for compatibility and have no effect: the embed worker is the only embedding writer.

## Trust boundary

The two documents are separate because they are not the same kind of surface.

| | Read API | Ingest API |
| --- | --- | --- |
| OpenAPI title | `Tapes API` | `Tapes Ingest API` |
| Intended callers | Clients through an edge gateway | In-cluster capture adapters only |
| Published stance | Query and operator surface | **Not internet-facing** |
| Persistence | Reads the derived model; delete/patch sessions; admin derive/repair | Appends immutable `raw_turns` (and transcript rows) |
| Org on the request | Not a client-chosen tenant. Reads scope to the single-tenant sentinel | Envelope `session.org_id` is cleared before store. Same sentinel as reads |

The ingest contract is explicit: these endpoints trust identity that arrives with the write. Publishing them as one document with the read API would imply a surface that is reachable and safe to call from outside.

Current tenancy is single-tenant. The write path forces `session.org_id` to empty and stores the same sentinel the read handlers use (`00000000-0000-0000-0000-000000000000`). A client cannot assert an org in the envelope and land rows the read side will never surface. The `org_id` field stays on the wire until the columns go.

`x-paper-auth-subject` can still stamp `auth_subject` on the transcript path. On the read side, `auth_subject` is a filter, not an identity grant: it narrows sessions and `/v1/stats` inside this tenant.

The API process does not implement a hosting topology. `GET /metrics` on both servers is outside any auth group (Prometheus scrape). Network policy, TLS, gateway grants, and who may reach `:8082` are deployment responsibilities. Do not infer a production boundary from local listen defaults or from the generated OpenAPI.

<Warning>
Do not put ingest behind the same edge gateway as the read API. The published ingest description exists so that mistake is obvious: a JWT holder who can POST turns can write capture data.
</Warning>

## What each contract publishes

Neither server checks in an OpenAPI file. Each compiles its document from the same `oasfiber` registrations that mount the routes, and serves it at `GET /openapi`.

### Read API (`:8081`)

`GET /openapi` on a running server is the **aggregate**: core routes plus each admitted cassette's operations rewritten under `/v1/cassettes/{name}`. The `api/CONTRACT` seal is the **core** document only (`tapes dev openapi api --docs-root ''`). Cassette churn does not move the seal.

| Area | Routes |
| --- | --- |
| Health | `GET /ping` — JSON string `pong` |
| Contract / viewer | `GET /openapi`, `GET /swagger` (HTML; not in the contract) |
| Metrics | `GET /metrics` (Prometheus; not in the contract) |
| Browser UI | `GET /` only with `--api-web-ui` / `api.web_ui` (HTML; not in the contract) |
| Sessions | `GET /v1/sessions`, `GET/PATCH/DELETE /v1/sessions/{id}`, traces, `raw_turns`, export |
| Traces / spans | `GET /v1/traces`, `GET /v1/traces/{trace_id}`, `GET /v1/traces/{trace_id}/spans/{span_id}` |
| Search / stats | `GET /v1/search/spans`, `GET /v1/stats` |
| Skills | `/v1/skills` and session-skill listing |
| MCP | `GET`/`POST`/`DELETE /v1/mcp` |
| Operator | `POST /v1/admin/seed/demo`, `POST /v1/admin/derive/run`, `POST /v1/admin/raw-turns/attribution-repair` |
| Cassettes | `GET /v1/cassettes`, per-cassette `openapi.json`, reverse-proxy `/v1/cassettes/{name}` and `/*` |

There is no `/v1/search`, `/v1/sessions/summary`, or hash-based session route. Session and trace/span paths use UUID ids. Semantic search exists only at `/v1/search/spans`.

The read API compresses responses. It is not the ingest write surface.

### Ingest API (`:8082`)

Routes are fixed at process start. `GET /openapi` is compiled once and cached. Capture adapters (`tapes-extproc`, `tapesctl`, `paperd`, `tapes backfill`) all write this envelope.

| Method | Path | Role |
| --- | --- | --- |
| `GET` | `/ping` | Liveness. `{ "status": "ok" }`. Does not check the database |
| `GET` | `/openapi` | This surface's contract (not in the document; circular) |
| `GET` | `/metrics` | Prometheus (not in the document) |
| `POST` | `/v1/ingest` | One completed LLM turn |
| `POST` | `/v1/ingest/transcript` | One harness transcript file or spawn-anchor row |

:::endpoint POST /v1/ingest Ingest one captured turn
Appends one completed turn to `raw_turns` **before** provider parsing. A turn that later 422s is still captured; a parser fix re-derives it.

Idempotent when the adapter supplies `meta.request_id`: a retry of the same turn dedupes at the raw layer.

The body may carry a reduced `response`, verbatim `raw_response` (base64), or both. Raw-only is reduced server-side with the shared `pkg/capture` reducers so two capture paths produce the same row for the same traffic.

**202** `{ "status": "accepted" }` — captured and queued. Derivation is asynchronous.

**400** malformed envelope or invalid session block.

**413** body exceeds `MaxIngestBodyBytes`.

**422** well-formed but unprocessable (unknown provider, and similar).

**502** a downstream dependency failed.
:::

:::endpoint POST /v1/ingest/transcript Ingest one harness transcript
Stores one main-session transcript, one subagent transcript, or a Codex spawn-anchor row. No node-path processing happens here; the deriver reconciles against the wire capture.

Dedup key includes a content hash of `records`. Re-uploading unchanged content returns **202** with `deduped: true`. A grown file appends a new version. The deriver reads the latest version per session, agent, and lifecycle kind.

**400** malformed body, invalid session, or `records` not a JSON array.

**413** over the same body limit.

**500** persist failed.

**501** driver does not host the raw-turn layer.
:::

## Body limit and error envelope

Every non-2xx on ingest uses `llm.ErrorResponse`:

```json
{ "error": "request body exceeds the ingest size limit" }
```

Fiber `BodyLimit` is `MaxIngestBodyBytes`, derived rather than a lone literal:

`MaxDecodedRequestBytes` (32 MiB) + `MaxRawResponseBytes` base64 expansion (`8 MiB * 4/3`) + 4 MiB reserve ≈ **46.67 MiB** (`48933546` bytes).

A POST over the limit is rejected **before parse**. The handler records one `tapes_ingest_writes_total{provider="unknown",status="reject_oversize"}` sample and a warn log with `content_length`, `limit`, and `path`. Only `POST /v1/ingest` and `POST /v1/ingest/transcript` take that path; a handler-returned 413 is not counted as oversize.

Separately, a `raw_response` larger than 8 MiB is dropped on persist and marked withheld/dropped. The turn can still land. That is not the HTTP 413.

The capture proxy and `tapes-extproc` share the same byte budget so they do not POST a body ingest will refuse.

## Sealed contracts

No generated OpenAPI document is checked in. What is checked in is a fingerprint:

| File | What it seals | How to recompute |
| --- | --- | --- |
| `api/CONTRACT` | Core read document, **prose stripped** | `tapes dev openapi api --docs-root ''` |
| `ingest/CONTRACT` | Ingest document, **prose stripped** | `tapes dev openapi ingest --docs-root ''` |

`api/openapi_seal_test.go` and `ingest/openapi_seal_test.go` recompile and compare. Moving a path, parameter, schema, or status code fails the test until the new `sha256:` value is written in the same change. Editing a Go doc comment is not a contract event. Text declared inline on a route registration is published surface and does move the seal.

A running binary has no source tree, so `GET /openapi` has route and operation prose but not per-field prose. From a checkout:

```bash
tapes dev openapi                 # read API, YAML, field prose from .
tapes dev openapi ingest
tapes dev openapi --format json
tapes dev openapi --docs-root ''  # shapes only — this is the sealed document
make contracts                    # writes both into ./build/contracts
```

The ingest seal exists because adapters live in this repo and outside it. An unannounced write-shape change is one those adapters discover in production.

## Data flow across the boundary

1. A capture adapter POSTs a turn or transcript to `:8082`.
2. Ingest persists the raw envelope first, then queues derivation.
3. The derive worker (in-process under `tapes serve`, or `tapes serve derive-worker`) projects `raw_turns` into sessions, traces, and spans. Re-derive is idempotent and prunes rows the current deriver no longer emits.
4. Clients read that projection on `:8081`. `DELETE /v1/sessions/{id}` removes the derived subtree and leaves `raw_turns` intact.
5. `tapesctl seed` and `POST /v1/admin/seed/demo` are **read-API** operator calls. They replay bundled corpora through the ingest write path, then derive.

Local `tapes serve` also runs an in-process embed worker (disable with `--embed-spans=false`). Embedding failures disable search; they do not take down ingest or the read API.

## Verify which surface you hit

<Steps>
<Step title="Confirm the stack">
```bash
tapes serve
```
Expect log lines for `starting proxy`, `starting api server`, and `starting ingest server` with the three listen addresses.
</Step>
<Step title="Read API">
```bash
curl -sS http://localhost:8081/ping
curl -sS http://localhost:8081/openapi | jq '.info.title, (.paths | keys | length)'
```
Expect a JSON `pong` and title `Tapes API`. With cassettes mounted, path count is larger than the core seal document.
</Step>
<Step title="Ingest API">
```bash
curl -sS http://localhost:8082/ping
curl -sS http://localhost:8082/openapi | jq '.info.title, [.paths | keys[]]'
```
Expect `{"status":"ok"}`, title `Tapes Ingest API`, and documented paths `/ping`, `/v1/ingest`, `/v1/ingest/transcript` only.
</Step>
</Steps>

## Failure modes

| Symptom | Cause | What to do |
| --- | --- | --- |
| Capture “succeeds”, no sessions | `tapes-url` is `:8081` | Pass `--tapes-url http://localhost:8082` on `start` / `capture` / `sync` |
| List/search/export fail after capture | Client still pointed at ingest | `tapesctl config set tapes-url http://localhost:8081` |
| `413` + `{"error":"request body exceeds the ingest size limit"}` | POST > `MaxIngestBodyBytes` | Check `tapes_ingest_writes_total{status="reject_oversize"}`; shrink the envelope or withhold raw bytes (`raw_response_withheld`) |
| `GET /ping` 200 on ingest, writes 502 | Process up, database/driver not | Ping is not a write probe |
| Read `/openapi` grew after a cassette admit | Aggregate document includes cassette ops | Expected. Core seal is unchanged |
| Seal test fails | Route, schema, or inline description moved | Recompile with `--docs-root ''` and bump the matching `CONTRACT` in the same PR |

## Next

<CardGroup>
<Card title="Read API" href="/read-api">
Core GET /openapi on :8081: sessions, traces, search, skills, admin, MCP, cassette merge, and CONTRACT rules.
</Card>
<Card title="Ingest API" href="/ingest-api">
POST /v1/ingest and /v1/ingest/transcript, body cap, JSON errors, and 413 reject_oversize.
</Card>
<Card title="Capture an agent" href="/capture-an-agent">
Point Claude, Codex, pi, or a generic client at capture; always write ingest on :8082.
</Card>
<Card title="Split the stack" href="/split-the-stack">
Run proxy, api, ingest, derive-worker, and embed-worker as separate processes.
</Card>
<Card title="Capture and derive" href="/capture-and-derive">
Append-only raw_turns and the idempotent projection into sessions, traces, and spans.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Wrong-port capture, ingest 413, and attribution-repair 200 vs 202.
</Card>
</CardGroup>

---

## 07. Cassettes

> cassette/v1alpha1 admission: OpenAPI plus x-tapes-cassette, path rewrite under /v1/cassettes/{name}, and operator-owned process lifecycle.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/07-cassettes.md
- Generated: 2026-08-18T20:12:55.896Z

### Source Files

- `pkg/cassette/v1alpha1/manifest.go`
- `pkg/cassette/v1alpha1/validate.go`
- `api/cassettes.go`
- `cmd/tapes/serve/cassettes.go`
- `pkg/config/cassettes.go`
- `docs/cassettes.md`

---
title: "Cassettes"
description: "cassette/v1alpha1 admission: OpenAPI plus x-tapes-cassette, path rewrite under /v1/cassettes/{name}, and operator-owned process lifecycle."
---

A cassette is an independently deployed HTTP service that extends the tapes read API on `:8081`. The API server fetches a configured full OpenAPI URL, admits the required root extension `x-tapes-cassette` (`kind` `cassette/v1alpha1`), rewrites every documented path under `/v1/cassettes/{name}`, and reverse-proxies client traffic to the cassette origin. tapes does not start, stop, pull, or configure the cassette process.

<Warning>
`cassette/v1alpha1` is an alpha contract. Manifest fields, admission rules, and runtime behavior are not a stable compatibility promise.
</Warning>

## Ownership

tapes is not a plugin host. A cassette may use any language or HTTP framework and does not have to import tapes. The operator owns process lifecycle, credentials, environment, image pull, listener port, PostgreSQL role/schema, and `SELECT` grants on contract views.

```mermaid
flowchart LR
  subgraph operator [Operator]
    start[Start cassette process]
    grant[Role, schema, grants]
    src["cassettes / --cassettes URL"]
  end
  subgraph cassette [Cassette origin]
    health["GET /ping"]
    spec["GET /openapi + x-tapes-cassette"]
    local["GET /api/{name}/..."]
  end
  subgraph api [tapes API :8081]
    runner[Fetch, admit, republish]
    disc["GET /v1/cassettes"]
    cached["GET /v1/cassettes/{name}/openapi.json"]
    agg["GET /openapi"]
    proxy["ALL /v1/cassettes/{name}/*"]
  end
  start --> cassette
  grant --> cassette
  src --> runner
  spec --> runner
  runner --> disc
  runner --> cached
  runner --> agg
  proxy --> local
```

The public cassette namespace lives on the same origin, port, and middleware as the rest of the read API. It is not a sidecar gateway beside `/v1`.

## What a cassette must serve

On its own listener a cassette exposes three surfaces:

| Surface | Default | Published by tapes? |
| --- | --- | --- |
| Health anchor | `/ping` | No. Recorded in the manifest; the current runner does not probe it. |
| OpenAPI anchor | `/openapi` | Fetched from the **operator-configured URL**, not from `api.openapi`. |
| API under local prefix | `prefix_path` default `api` | Yes. Rewritten to `/v1/cassettes/{name}`. |

Do not list the health or OpenAPI anchors as operations in the cassette document. Every path in `paths` must sit under the local prefix plus cassette name, or admission refuses the **whole** document.

For a cassette named `summary` with default `prefix_path = "api"`:

```text
Cassette listener              Public read API
GET /ping                      (not republished)
GET /openapi                   GET /v1/cassettes/summary/openapi.json  (cached rewrite)
GET /api/summary/reports       GET /v1/cassettes/summary/reports       (proxied)
```

The cassette never sees `/v1/cassettes`. The registry swaps the public prefix for the local prefix before the reverse proxy.

### Path mapping

| `prefix_path` | Cassette-local path | Public path |
| --- | --- | --- |
| omitted or `api` | `/api/summary/reports` | `/v1/cassettes/summary/reports` |
| `extensions/v2` | `/extensions/v2/summary/reports` | `/v1/cassettes/summary/reports` |
| `/` | `/summary/reports` | `/v1/cassettes/summary/reports` |

`prefix_path` segments must be lowercase letters, digits, dashes, or underscores, each starting with a letter or digit. Surrounding slashes are trimmed. An empty string after defaults is invalid: omit the field for `"api"`, or set `"/"` for no prefix.

## Two encodings of one schema

A cassette normally publishes the same declaration twice:

- **`cassette.toml`** — read by a registry, installer, or Compose stack **before** the process starts (image, port, views, config schema). The tapes API server does **not** read this file.
- **`x-tapes-cassette` on the OpenAPI root** — required for admission. tapes fetches only this copy.

They are two encodings of one schema. Parse TOML through `pkg/cassette/manifest` (TOML → JSON → the same strict parser) and compare `Digest()` values. Defaults are applied before canonicalization; set-like arrays are sorted, so an omitted default and an explicit default share identity.

There is no `tapes cassette validate` command. Callers must `Parse` then `Validate` against the contracts the target core serves.

## Minimum `cassette/v1alpha1` manifest

```toml
kind = "cassette/v1alpha1"

[cassette]
name = "summary"
version = "0.1.0"

[depends]
core = "v1"
```

Omitted API anchors default to:

```toml
[api]
health = "/ping"
openapi = "/openapi"
prefix_path = "api"
```

The same document must appear as JSON on the OpenAPI root:

```json
{
  "openapi": "3.1.0",
  "info": {"title": "Summary cassette", "version": "0.1.0"},
  "x-tapes-cassette": {
    "kind": "cassette/v1alpha1",
    "cassette": {"name": "summary", "version": "0.1.0"},
    "depends": {"core": "v1"},
    "api": {
      "health": "/ping",
      "openapi": "/openapi",
      "prefix_path": "api"
    }
  },
  "paths": {
    "/api/summary/reports": {
      "get": {
        "operationId": "listReports",
        "responses": {"200": {"description": "Reports"}}
      }
    }
  }
}
```

The Go parser is strict: duplicate keys, unknown fields, trailing JSON values, and an unsupported `kind` are errors. Parsing applies defaults and does **not** run semantic validation.

### Identity

| Field | Required | Rules |
| --- | --- | --- |
| `kind` | yes | Exactly `cassette/v1alpha1`. |
| `cassette.name` | yes | `^[a-z][a-z0-9-]{0,30}[a-z0-9]$` (2–32 chars). Reserved: `public`, `tapes`, any `pg_` prefix. |
| `cassette.version` | yes | Non-empty. Alpha does not require SemVer syntax. |
| `cassette.display_name` | no | Human-readable name. |
| `cassette.description` | no | Human-readable summary. |
| `cassette.license` | no | License identifier or prose. |
| `cassette.homepage` | no | Absolute `http` or `https` URL. |
| `cassette.image` | no | Image reference, no surrounding whitespace. Requires `port`. tapes does not pull or run it. |
| `cassette.port` | no | 1–65535. Requires `image`. |
| `x-source-digest` | no | `sha256:` + 64 lowercase hex. Shape-checked only. |

The name is shared across several namespaces. Quote derived PostgreSQL identifiers — a legal name may contain a dash.

```text
public route     /v1/cassettes/<name>
Postgres schema  <name>
Postgres role    cassette_<name>
```

### `depends`

<ParamField body="depends.core" type="string" required>
Major tapes contract (`v1`, `v2`, …), not a tapes binary version. Current default contract is `v1`. Admission fails if this core does not serve that contract.
</ParamField>

<ParamField body="depends.views" type="string[]">
Unique lowercase PostgreSQL identifiers, at most 63 bytes. `raw_turns` is forbidden. Grants are derived as `tapes_<core>.<view>` (for example `tapes_v1.spans`). tapes does not verify that the view exists or apply the grant.
</ParamField>

`tapes_v1` publishes four views. Grant these names, never the date-versioned physical projection tables behind them:

| View | Fronts |
| --- | --- |
| `tapes_v1.sessions` | sessions table |
| `tapes_v1.spans` | current span projection |
| `tapes_v1.span_turns` | current span-turn projection |
| `tapes_v1.span_links` | current span-link projection |

### Owned tables and config schema

`[[tables]]` names are unique lowercase PostgreSQL identifiers (≤63 bytes) in the cassette's own schema. Discovery publishes them qualified (`summary.daily_summary`). tapes does not create the schema or run migrations.

`[[config]]` describes values the **deployment** injects. tapes publishes the schema, never runtime values, and does not set environment variables.

| Rule | Detail |
| --- | --- |
| Key | Dotted lower-snake segments; unique as keys and after env projection. |
| Env convention | `llm.model` → `CASSETTE_LLM_MODEL` |
| Types | `string`, `int`, `bool`, `duration` (Go duration), `json` (string containing JSON) |
| `enum` | `string` only; values unique |
| `min` / `max` | `int` only; inclusive; `min` ≤ `max` |
| `secret` | Must not declare a default. Discovery withholds `default` for secrets. |

Discovery projects `key`, `type`, `required`, `secret`, `default`, and `description`. `enum` / `min` / `max` stay in the manifest.

## Admission and refresh

Configure exact full OpenAPI document URLs. Config validation only checks URL shape; fetch and admission stay retryable so an unreachable sidecar does not fail `tapes serve`.

<ParamField body="cassettes" type="string[]">
Full `http` or `https` URLs with a host. No userinfo, no fragment. Duplicates are rejected. Equivalent: `cassettes` in `config.toml`, `TAPES_CASSETTES`, `--cassettes` (comma-separated or repeated). Flags override config.
</ParamField>

<ParamField body="--cassette-refresh" type="duration">
How often to re-fetch documents after startup. Default `30s`. `<= 0` disables the ticker after the startup window.
</ParamField>

Startup: retry every **500ms** for **15s**, then settle on `--cassette-refresh`. An unresolved source does not prevent the API from listening.

Each refresh:

1. `GET` the source with `Accept: application/json`, **10s** timeout, **8 MiB** body cap.
2. Refuse redirects (the document origin must be the proxy origin).
3. Initial or changed documents must be HTTP 200. Conditional refresh may send `If-None-Match` and accept 304 only if that source is already published.
4. Parse OpenAPI and required `x-tapes-cassette`; `Validate` against this core's contracts.
5. Pin the source to the first admitted name. A later name change is a rejection.
6. Configured order is the tiebreak when two sources claim one name.
7. `RewritePrefix` from local prefix to `/v1/cassettes/{name}`. Strip `servers` at root, path, and operation so clients do not target the private listener.
8. Compile the rewritten document: unique `operationId`s **within that cassette**, every operation has responses. Anonymous operations are allowed; the aggregate prefixes IDs. Component names are namespaced (`hello-world` → `hello_world_`).
9. Extract `x-tapes-mcp` tools. A malformed tool refuses the whole document.
10. Cache the rewritten bytes, then register the instance. Cache-before-register so discovery never names a cassette without a spec.

A later refresh failure marks the cached document `stale` and keeps serving it. Removing the URL from `cassettes` withdraws the instance and evicts the spec.

`manifest_digest` in discovery is the canonical **manifest** digest. The `ETag` on `/v1/cassettes/{name}/openapi.json` is `sha256` of the **republished OpenAPI** document. They answer different questions and need not match.

## Public HTTP surface

Cassette traffic is served on the read API (default `http://localhost:8081`). Proxy wildcards are **not** OpenAPI operations; cassette operations appear in `GET /openapi` under their rewritten paths.

| Method | Path | Role |
| --- | --- | --- |
| `GET` | `/v1/cassettes` | Discovery: admitted fleet plus source `problems`. |
| `GET` | `/v1/cassettes/{name}/openapi.json` | Cached rewritten spec. Core-owned; not proxied. |
| `GET` | `/openapi` | Aggregate: live core registrations plus every cached cassette spec. |
| `ALL` | `/v1/cassettes/{name}` and `/{name}/*` | Reverse proxy to the cassette origin. |

Registration order is load-bearing: `/openapi.json` is registered before the proxy wildcards.

:::endpoint GET /v1/cassettes Discover installed cassettes
**200** — `contract_version`, `cassettes[]`, `problems[]`.

Each entry includes `name`, `version`, `display_name`, `description`, `route_prefix`, `depends`, schema-qualified `tables`, config **schema**, `openapi_path`, `openapi_status` (`fresh` / `stale` / `missing`), and `manifest_digest`.

`problems[].subject` is the configured URL with credentials redacted. `problems[].reason` is not a stable machine code.
:::

:::endpoint GET /v1/cassettes/{name}/openapi.json Cached republished OpenAPI
Served from memory so a down cassette still has a readable surface.

- `ETag: "<sha256:...>"` over the republished document.
- `If-None-Match` matching that ETag returns **304**.
- Unknown name → **404** `unknown_cassette`.
- Installed but never fetched → **503** `spec_unavailable`.
:::

### Proxy behavior

`Lookup` matches whole path segments so `/v1/cassettes/sum` does not shadow `/v1/cassettes/summary`. The forwarded path is `LocalPrefix + remainder`.

Forwarded requests:

- Target origin is scheme + host of the configured OpenAPI URL (same origin as the spec).
- Set `X-Tapes-Cassette: {name}` and standard `X-Forwarded-*`.
- Drop inbound `Forwarded` / `X-Forwarded-*` before rewriting.
- Buffer complete request and response bodies. Treat the surface as JSON APIs; streaming is not supported.

Cassette down → **502** `cassette_unavailable`. Unusable target URL → **502** `bad_target`. Unknown path → **404** `unknown_cassette`. Aggregate compile failure → **500** `aggregate_failed`.

Error envelope:

```json
{"error": "cassette_unavailable", "message": "cassette \"summary\" at http://127.0.0.1:9999 did not respond: ..."}
```

`error` is stable; `message` is not.

## Operator registration

<Steps>
<Step title="Start the cassette yourself">
Run the process with your supervisor, Compose, or Kubernetes. Supply `CASSETTE_*` values, DSN, and secrets directly. Example image metadata in `pkg/cassette/examples/hello-world` uses port `9999`.
</Step>
<Step title="Point tapes at the OpenAPI URL">
<CodeGroup>
```toml title=".tapes/config.toml"
cassettes = ["http://127.0.0.1:9999/openapi"]
```

```bash title="CLI / env"
tapes serve --cassettes=http://127.0.0.1:9999/openapi
tapes serve api --cassettes=http://127.0.0.1:9999/openapi
TAPES_CASSETTES=http://127.0.0.1:9999/openapi tapes serve
```
</CodeGroup>
</Step>
<Step title="Verify discovery and a proxied call">
```bash
curl http://localhost:8081/v1/cassettes
curl http://localhost:8081/v1/cassettes/hello-world/openapi.json
curl http://localhost:8081/openapi
curl http://localhost:8081/v1/cassettes/hello-world/hello
```
</Step>
</Steps>

Bundled examples:

- `pkg/cassette/examples/hello-world` — HTTP service, `cassette.toml`, image, `provision.sql`, Compose.
- `pkg/cassette/examples/mcp-tool` — one `POST` tool `mcp-tool.ping`.

## Database grant plan

tapes publishes the declaration. The deployment applies it.

For name `summary`, `depends.core = "v1"`, views `sessions` and `spans`, table `daily_summary`:

```text
role        cassette_summary
own schema  summary
SELECT      tapes_v1.sessions
SELECT      tapes_v1.spans
owned table summary.daily_summary
```

`hello-world` declares `views = []` and table `hello`. Its example `provision.sql` creates role `"cassette_hello-world"` and `GRANT CREATE ON DATABASE` only; the cassette migrates its own table at startup.

## MCP advertisement

Mark a **POST** operation with `x-tapes-mcp` (not a root field). After admission the tool is `{cassette}.{name}` (for example `summary.summarize_session`). Changing a tool changes the OpenAPI ETag, not the manifest digest.

Constraints that refuse the whole cassette document:

- OpenAPI 3.1; `POST` only; no path/query/header/cookie parameters.
- Inline required `application/json` body whose schema resolves to an object.
- Successful response is a JSON object.
- Local `#/components/schemas/...` refs only; no remote refs or request-body `$ref`.
- At most **128** tools per cassette; tool name `^[A-Za-z0-9_.-]+$`; qualified name ≤ 128 bytes.

Annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) are client hints, not authorization. Unknown extension fields are ignored. Tool calls use the admitted origin, forward caller identity headers, set `X-Tapes-Cassette`, refuse redirects, and map non-2xx to MCP tool errors.

## Troubleshooting

| Symptom | Cause / check |
| --- | --- |
| Cassette missing from `GET /v1/cassettes` | Source not configured, still in startup retry, or listed under `problems`. |
| `openapi_status: stale` | Last refresh failed; last good document is still served. |
| `unknown_cassette` | Name not installed, or public path does not match a registered prefix. |
| `spec_unavailable` | Installed in the registry but no document cached yet. |
| `cassette_unavailable` | Origin did not respond. Start the process; tapes will not. |
| Whole document refused | Path outside local prefix, missing `x-tapes-cassette`, unsupported `depends.core`, compile lint, or bad `x-tapes-mcp`. |
| Source “changed name” | Same URL now embeds a different `cassette.name`. Use a new URL or restore the name. |
| Duplicate name | Earlier `cassettes` entry already owns that name. |
| Streaming / SSE fails | Proxy buffers whole bodies. |
| SQL identifier errors | Quote `"hello-world"` / `"cassette_hello-world"`. |

<Check>
After a good admission, `GET /v1/cassettes` lists the name, `openapi_status` is `fresh`, `/v1/cassettes/{name}/openapi.json` returns rewritten paths, and one proxied route matches a cassette-local handler.
</Check>

## Builder checklist

- Health **200** at the declared health path; OpenAPI JSON **200** at the configured URL.
- Embed `cassette/v1alpha1` at `x-tapes-cassette`.
- Keep every operation under `/<prefix_path>/<name>` (or `/<name>` when `prefix_path` is `/`).
- Unique `operationId`s when supplied; every operation declares responses.
- Same canonical digest for TOML and embedded JSON.
- Provision role, schema, and `tapes_v1.*` grants outside tapes.
- Avoid streaming until the proxy supports it.

## Related pages

<CardGroup>
<Card title="Run a cassette locally" href="/run-a-cassette">
Admit hello-world with Compose, `--cassettes`, and tapesctl against the republished surface.
</Card>
<Card title="Read API" href="/read-api">
Compiled `GET /openapi` on `:8081`, including the cassette proxy namespace and CONTRACT seals.
</Card>
<Card title="MCP" href="/mcp">
Streamable HTTP `/v1/mcp`, cassette tools marked `x-tapes-mcp`, and transport limits.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
`cassettes` array, flag / `TAPES_` / `config.toml` precedence.
</Card>
<Card title="Read API vs ingest" href="/read-vs-ingest">
Why cassette traffic is on the public read port, not the private ingest write path.
</Card>
</CardGroup>

---

## 08. Capture an agent

> Point Claude, Codex, pi, or a generic provider client at capture: tapesctl start/capture vs a fixed-port proxy on :8080, always writing ingest on :8082.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/08-capture-an-agent.md
- Generated: 2026-08-18T20:13:04.264Z

### Source Files

- `docs/integrations.md`
- `proxy/proxy.go`
- `cmd/tapes/serve/proxy/proxy.go`
- `pkg/capture/reducer.go`
- `ingest/openapi.go`
- `cmd/tapes/auth/auth.go`

---
title: "Capture an agent"
description: "Point Claude, Codex, pi, or a generic provider client at capture: tapesctl start/capture vs a fixed-port proxy on :8080, always writing ingest on :8082."
---

`tapesctl start` and `tapesctl capture` write completed turns to the **private ingest API** (`POST /v1/ingest`, `POST /v1/ingest/transcript`) on `:8082` by default. The all-in-one `tapes serve` process also binds a **fixed-port transparent proxy** on `:8080` for Anthropic, OpenAI, or Ollama clients that you point at the proxy yourself. Those two lanes share `raw_turns` and the deriver; they are not the same listen address and they are not the read API on `:8081`.

<Warning>
Capture commands must target ingest (`http://localhost:8082`). A `tapesctl start` / `capture` / `sync` pointed at `:8081` reports success and stores nothing.
</Warning>

## Ports

`tapes serve` starts the proxy, read API, ingest, in-process derive worker, and (by default) the embed worker together.

| Listen | Surface | Who talks to it |
| --- | --- | --- |
| `:8080` | Transparent provider proxy | Agent or app whose **base URL** is the proxy. Forwards to `proxy.upstream`. |
| `:8081` | Read API | `tapesctl sessions`, `search`, `export`, `seed`; `GET /ping` |
| `:8082` | Private ingest API | `tapesctl start`, `capture`, `sync`; `tapes-extproc` / other capture adapters |

Defaults: `proxy.provider=ollama`, `proxy.upstream=http://localhost:11434`. Change them for a hosted provider:

```bash
tapes serve --provider anthropic --upstream https://api.anthropic.com
```

<ParamField body="--provider" type="string">
LLM wire schema the **fixed-port** proxy parses: `anthropic`, `openai`, or `ollama`. Default `ollama`.
</ParamField>

<ParamField body="--upstream" type="string">
Upstream base URL the **fixed-port** proxy forwards to. Default `http://localhost:11434`.
</ParamField>

<ParamField body="--proxy-listen / -p" type="string">
Proxy listen address. Default `:8080`.
</ParamField>

<ParamField body="--ingest-listen / -i" type="string">
Ingest listen address. Default `:8082`.
</ParamField>

<ParamField body="--tapes-url" type="string">
`tapesctl` server URL. Flag beats `TAPES_URL`, which beats `~/.tapes/config.toml`. Capture commands need ingest; read commands need the read API.
</ParamField>

Configure the client once for **read** commands, and pass ingest explicitly when capturing:

```bash
tapesctl config set tapes-url http://localhost:8081
```

```mermaid
flowchart LR
  subgraph agents [Agents]
    harness["claude / codex / pi"]
    generic["Generic provider client"]
  end

  subgraph client [tapesctl]
    jit["Just-in-time capture proxy"]
    window["capture window"]
  end

  subgraph serve ["tapes serve"]
    proxy[":8080 proxy"]
    ingest[":8082 ingest"]
    api[":8081 read API"]
    raw[("PostgreSQL raw_turns")]
    derive["derive worker"]
  end

  harness --> jit
  harness --> window
  jit -->|"POST /v1/ingest and /v1/ingest/transcript"| ingest
  window -->|"POST /v1/ingest and /v1/ingest/transcript"| ingest
  generic --> proxy
  jit --> upstream["Upstream provider"]
  proxy --> upstream
  proxy --> raw
  ingest --> raw
  raw --> derive
  derive --> api
```

The `:8080` proxy persists through its worker pool into the same Postgres `raw_turns` layer ingest writes. It does not HTTP-POST to `:8082`. `tapesctl` and gateway adapters (`tapes-extproc`) do.

## Prerequisites

<Steps>
<Step title="Install both binaries">
`tapes` is the server. `tapesctl` is the client (separate repository). Client commands that used to live on `tapes` are not on this binary.

```bash
curl -fsSL https://download.tapes.dev/install | bash
curl -sSfL https://download.tapes.dev/tapesctl/install | bash
```
</Step>
<Step title="Start local dependencies and the stack">
```bash
tapes local up
tapes serve
```

`tapes local up` writes PostgreSQL / pgvector / Ollama settings into the active `.tapes/config.toml`. It pulls the default embedding model (`embeddinggemma`), not every chat model.
</Step>
<Step title="Confirm the three surfaces">
```bash
curl http://localhost:8081/ping
curl http://localhost:8082/ping
tapes status
```

Read-API `GET /ping` is not a proxy or ingest health check.
</Step>
</Steps>

<Note>
`tapes auth` stores OpenAI or Anthropic keys in `.tapes/credentials.toml` for **span embedding** and **skill generation**. Capture is transparent: the agent forwards its own credentials. Do not treat `tapes auth` as a capture prerequisite.
</Note>

## Two capture lanes

| Lane | Command | Listen | Write path | Use when |
| --- | --- | --- | --- | --- |
| Just-in-time | `tapesctl start <harness>` | Ephemeral loopback proxy owned by `tapesctl` | `POST` ingest `:8082` | The client launches the agent (`claude`, `codex`, `pi`) |
| Capture window | `tapesctl capture <plugin>` | Bind address from the plugin handoff | `POST` ingest `:8082` | The agent launches itself (`codex-app`) |
| Fixed-port | `tapes serve` (or `tapes serve proxy`) | `:8080` | Proxy worker pool → Postgres `raw_turns` | Generic client, long-lived `ANTHROPIC_BASE_URL` / provider base URL |

`start` prints turn counts and the **harness** session id on exit. `capture` prints how many sessions it saw, not turn counts. Neither printed id is the Tapes session id that `tapesctl sessions get` takes — list sessions on `:8081` and copy the id from there.

## Harness matrix

| Harness | Lane | Plugin first |
| --- | --- | --- |
| `claude` | `tapesctl start claude` | none |
| `codex` | `tapesctl start codex` | none |
| `pi` | `tapesctl start pi` | `tapesctl plugin install pi` |
| `codex-app` | `tapesctl capture codex-app` | `tapesctl plugin install codex-app` |

`--schema` is valid only on `pi`. Passing `--schema` to `claude` or `codex` is an error: each of those harnesses speaks exactly one schema.

<Tabs>
<Tab title="Claude">

```bash
tapesctl start claude --tapes-url http://localhost:8082
```

`tapesctl` starts a loopback capture proxy, sets Claude Code's `ANTHROPIC_BASE_URL` to it, launches `claude`, and POSTs captured turns to ingest. Agent flags go after `--`:

```bash
tapesctl start claude --tapes-url http://localhost:8082 -- --worktree
```

Claude also writes transcripts on disk. Those rows carry subagent fork edges the wire traffic alone cannot show (`tool_use_id` / per-agent files). `start` tails them live. For a session that ran without capture:

```bash
tapesctl sync --tapes-url http://localhost:8082
```

`sync` sweeps the last seven days by default. `--since-days 0` sweeps everything. Re-push is safe: transcript ingest deduplicates on a content hash (`deduped=true` when unchanged).

Fixed-port alternative (no `tapesctl` proxy):

```bash
tapes serve --provider anthropic --upstream https://api.anthropic.com
ANTHROPIC_BASE_URL=http://localhost:8080 claude
```

</Tab>
<Tab title="Codex CLI">

```bash
tapesctl start codex --tapes-url http://localhost:8082
```

Same just-in-time pattern as Claude. Codex subagent identity on the wire is the `session-id` / `thread-id` pair: root turns have `thread-id == session-id` and resolve to an empty capture `thread_id`; only spawned threads keep a distinct id.

</Tab>
<Tab title="Codex desktop">

The ChatGPT desktop app launches itself, so the lane is a capture window, not `start`.

```bash
tapesctl plugin install codex-app
tapesctl capture codex-app --tapes-url http://localhost:8082
```

`plugin install` writes the handoff and points the app's Codex configuration at the capture address. `capture` reads that handoff and binds it. Running `capture` before install fails and tells you to install.

`plugin uninstall codex-app` removes the provider entry and the handoff. The plugin stays registered with Codex; the command prints the `codex plugin remove` line that finishes uninstall.

</Tab>
<Tab title="pi">

pi is captured by an installed extension. Install is required, not optional: `start pi` refuses to run when the extension is missing, before anything is bound.

```bash
tapesctl plugin install pi
tapesctl start pi --tapes-url http://localhost:8082
```

pi can redirect several providers through one endpoint, so it is the only harness that takes `--schema`:

```bash
tapesctl start pi --tapes-url http://localhost:8082 --schema openai
```

</Tab>
</Tabs>

## Generic clients on `:8080`

Any Anthropic-, OpenAI-, or Ollama-compatible application can use the fixed-port proxy. Set the client's provider base URL to `http://localhost:8080` and run `tapes serve` with the matching `--provider` and `--upstream`. Preserve the path convention the client and provider expect.

Default stack (Ollama-compatible):

```bash
tapes serve
curl http://localhost:8080/api/chat \
  -H 'Content-Type: application/json' \
  -d '{"model":"qwen3-coder:30b","messages":[{"role":"user","content":"hello"}],"stream":false}'
```

Pull the chat model yourself. `tapes local up` does not pull completion models.

<CodeGroup>

```bash Anthropic
tapes serve --provider anthropic --upstream https://api.anthropic.com
# point the client at http://localhost:8080
```

```bash OpenAI-compatible
tapes serve --provider openai --upstream https://api.openai.com
# point the client at http://localhost:8080
```

```bash Ollama
tapes serve --provider ollama --upstream http://localhost:11434
# point the client at http://localhost:8080
```

</CodeGroup>

Optional request header `X-Tapes-Agent-Name` tags the turn. The proxy is otherwise transparent: it copies client headers upstream (harness thread headers are observed, not stripped).

The local proxy attaches an **empty** session envelope. Ingest/session-aware persist then mints a `sessions` row keyed by a synthetic `harness_session_id` derived from the turn's Merkle root, so generic `:8080` traffic still appears in the read API. Harness captures from `tapesctl` send a real session envelope instead.

<Info>
Embeddings are **not** written at capture time. `tapes serve embed-worker` (on by default inside `tapes serve`) is the single writer. Embedding flags on `tapes serve proxy` are accepted and ignored.
</Info>

## What ingest accepts

Ingest is a separate sealed contract (`GET http://localhost:8082/openapi`). It is not internet-facing: it trusts org identity in the envelope / gateway headers (`x-paper-auth-org-id`, `x-paper-auth-subject`). Legitimate callers are in-cluster adapters (`tapesctl`, `tapes-extproc`, paperd).

| Method | Path | Success | Notes |
| --- | --- | --- | --- |
| `GET` | `/ping` | `200` `{"status":"ok"}` | Process is serving; does not check Postgres |
| `POST` | `/v1/ingest` | `202` `{"status":"accepted"}` | One completed LLM turn |
| `POST` | `/v1/ingest/transcript` | `202` `{status, deduped, records, agent_id?}` | Main transcript, one subagent file, or Codex spawn-anchor row |
| `GET` | `/openapi` | `200` | This surface's contract only |

:::endpoint POST /v1/ingest Append one completed turn
The raw envelope is persisted **before** provider parse, so a parse failure is still captured. Idempotent when the adapter supplies `meta.request_id`.

**Body (`TurnPayload`)** — `provider` (`openai` \| `anthropic` \| `ollama`), `request` (raw provider JSON), optional reduced `response`, optional `raw_response` (verbatim upstream bytes, base64 in JSON), optional `raw_response_encoding`, optional `raw_response_withheld`, `meta`, optional `session`, optional `agent_name`.

**Errors:** `400` malformed envelope or session block · `413` over the body cap · `422` well-formed but unprocessable (unknown provider) · `502` downstream / worker saturation.

Raw-only payloads are reduced server-side with the shared `pkg/capture` reducers so two adapters can land identical rows for identical traffic.
:::

:::endpoint POST /v1/ingest/transcript Append one harness transcript
Dedup key includes a content hash of `records`. Unchanged re-upload → `deduped=true`. A grown file appends a new version. The deriver reads the latest version per `(session, agent, lifecycle kind)`.

`kind` empty = Codex spawn evidence. `kind=interacted` is stored and ignored by derivation so it cannot supersede a spawn anchor. `501` if the driver has no raw-turn layer.
:::

Body ceiling is `MaxIngestBodyBytes` = 32 MiB decoded request + base64 of an 8 MiB raw response + 4 MiB reserve (~46.67 MiB). The proxy rejects oversized **provider** requests with HTTP `413` on the same constant so the capture layer is not a tighter gate than Anthropic's 32 MB request contract. Oversize ingest POSTs use the JSON error envelope `{"error":"..."}` and increment `tapes_ingest_writes_total{provider="unknown",status="reject_oversize"}`.

The proxy HTTP client timeout is **5 minutes**. Capture jobs that miss the worker pool (256 slots, 256 MiB retained-byte budget, 3 workers) are dropped on the proxy path or returned as `502` on ingest.

## Verify

```bash
curl http://localhost:8081/ping
tapes status
tapesctl sessions list --tapes-url http://localhost:8081
```

A captured session appears in that list after derive runs (in-process under `tapes serve`). Use the listed Tapes session id for `sessions get`, `traces`, `raw-turns`, and export — not the harness id `start` printed.

Stop foreground `tapes serve` with `Ctrl-C`. `tapes local down` removes bootstrap containers and keeps PostgreSQL data unless `--wipe` is supplied.

## Failure modes

| Symptom | Cause | Fix |
| --- | --- | --- |
| Capture "succeeds", `sessions list` empty | `--tapes-url` / `TAPES_URL` / config pointed at `:8081` | Pass `http://localhost:8082` on `start` / `capture` / `sync` |
| `start pi` refuses before bind | Extension missing | `tapesctl plugin install pi` |
| `capture codex-app` fails immediately | Plugin / handoff missing | `tapesctl plugin install codex-app` first |
| `--schema` rejected | Used on `claude` or `codex` | Only `pi` accepts `--schema` |
| `sessions get` 404 on the printed id | That id is the harness session id | Copy the id from `tapesctl sessions list` |
| Ingest `413` | Envelope over `MaxIngestBodyBytes` | Shrink the turn, or expect `raw_response_withheld` / dropped raw bytes |
| Generic client talks to `:8081` or `:8082` | Those are Tapes contracts, not provider APIs | Set the provider **base URL** to `:8080` |
| Compose capture from the host cannot reach ingest | `docker-compose.yaml` publishes `8080` and `8081` only | Publish `8082` or run `tapes serve` on the host |
| No search hits after capture | Embed worker off or wrong embed keys | Capture still landed; see embedding config. Capture does not need `tapes auth` |

<AccordionGroup>
<Accordion title="What the fixed-port proxy actually captures">
Only `POST` requests with a non-empty body are treated as chat turns. The handler parses with `proxy.provider`, then streams or one-shots to `proxy.upstream + request path`. Successful `200` chat responses are enqueued asynchronously; the client already has the upstream bytes. Failed parse of the response skips enqueue (request parse failure still forwards).
</Accordion>
<Accordion title="Thread headers the proxy observes">
Claude Code: `x-claude-code-agent-id` (absent on the main thread). Codex: `session-id` + `thread-id`; sub-thread only when they differ and both are present. These headers stay on the upstream request. Canonical spelling is pinned in `fixtures/thread/` and must match `extproc/headers`.
</Accordion>
</AccordionGroup>

## Next

<CardGroup>
<Card title="Capture and derive" href="/capture-and-derive">
How `raw_turns` is appended and projected into sessions, traces, and spans.
</Card>
<Card title="Read API vs ingest" href="/read-vs-ingest">
Why `:8081` and `:8082` are different contracts and trust boundaries.
</Card>
<Card title="Ingest API" href="/ingest-api">
Envelope fields, `413` / `reject_oversize`, and `GET /openapi` on ingest.
</Card>
<Card title="Gateway capture" href="/gateway-capture">
`tapes-extproc` Envoy path that POSTs the same ingest contract.
</Card>
<Card title="Inspect and export" href="/inspect-and-export">
List the session you just captured and export JSONL.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Wrong-port capture and other operator failures.
</Card>
</CardGroup>

---

## 09. Search spans

> Semantic search over embedded main-conversation LLM spans via GET /v1/search/spans, tapesctl search, and the MCP search tool.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/09-search-spans.md
- Generated: 2026-08-18T20:14:55.497Z

### Source Files

- `api/search_spans_handler.go`
- `pkg/spanembed/spanembed.go`
- `pkg/embedworker/worker.go`
- `api/mcp/search.go`
- `docs/search.md`
- `pkg/embeddings/embedder.go`

---
title: "Search spans"
description: "Semantic search over embedded main-conversation LLM spans via GET /v1/search/spans, tapesctl search, and the MCP search tool."
---

`GET /v1/search/spans` on the read API (`:8081`) embeds the query text and runs pgvector similarity over the `span_embeddings` projection. Hits are individual **main-conversation LLM spans** (`kind=llm`, `call_kind=main`), each carrying session, trace (turn), span id, score, turn prompt, snippet, model, and start time. There is no `/v1/search` route, and search does not return sessions, tool/event spans, shadow LLM calls, or in-memory merkle content.

`tapesctl search` and the MCP tool `search` call the same span searcher. `tapesctl` is the client binary (separate repository); this server owns the embed worker and the HTTP/MCP surfaces.

<Note>
Ingest never embeds. Capture writes `raw_turns` on `:8082`; the deriver projects spans; the embed worker writes vectors. A derived span is invisible to search until an embed pass has stored it.
</Note>

## What a hit is

Each result is one span, not a session. Clients jump to the matched turn with `session_id` + `trace_id` + `span_id`.

| Field | Meaning |
| --- | --- |
| `trace_id` | Derived turn the span belongs to |
| `span_id` | Matched span |
| `session_id` | Session when attribution exists; omitted when the span derived without one |
| `score` | Similarity: `1 -` pgvector cosine distance (`<=>`) of the best-matching chunk |
| `user_prompt` | Prompt of that turn (HTTP always includes the key, including `""`) |
| `snippet` | Preview of the span's embedded delta text, truncated at 280 runes plus `…` |
| `model` | Model that produced the span (not the embedding model) |
| `started_at` | Span start time |

Search is scoped to this deployment's single tenant (`00000000-0000-0000-0000-000000000000`). A caller-supplied `X-Tapes-Org-Id` header is ignored. Header-less MCP search uses the same nil-org bucket.

## What gets embedded

Only spans that pass all of these enter `span_embeddings`:

- `kind = llm` and `call_kind = main` (permission checks, title generation, and other shadow calls are excluded)
- Rendered delta-only text is non-empty: text blocks from stored `input` plus `output`
- Tool payloads, thinking, images, and harness tags (`<system-reminder>`, hook context, command framing) are stripped before hashing and embedding

Embeddings are keyed by `(org_id, trace_id, span_id)` and a SHA-256 of the rendered text. Unchanged content under the same embedding model is skipped. Switching `embedding.model` re-embeds. A re-derive that prunes or reclassifies a span orphans its rows; the next pass deletes them.

Oversized text is split into chunk rows (`chunk_idx` 0..N-1). Search over-fetches `top_k * 4` nearest chunks, then collapses to one hit per span using the best chunk. Rendered text above ~1 MiB (`DefaultMaxTextBytes`) is recorded as a deterministic `too_large` failure and not retried until content or model changes. Transient provider errors stay un-embedded and retry on the next pass.

<Warning>
`embedding.model` and `embedding.dimensions` must match the pgvector column. `EnsureSchema` fail-fasts if an existing `span_embeddings` table was created with a different size; pgvector cannot resize the column in place.
</Warning>

```mermaid
flowchart LR
  subgraph writePath [Write path]
    Ingest["ingest :8082"]
    Raw["raw_turns"]
    Derive["derive-worker"]
    Spans["spans llm + main"]
    EmbedW["embed-worker"]
    Vec["span_embeddings"]
    Ingest --> Raw --> Derive --> Spans --> EmbedW --> Vec
  end
  subgraph readPath [Read path :8081]
    Q["Embed query"]
    API["GET /v1/search/spans"]
    API --> Q --> Vec
  end
  Provider["embedding.provider"]
  EmbedW --> Provider
  Q --> Provider
```

## Prerequisites

<Steps>
<Step title="Bootstrap storage and an embedder">
`tapes local up` provisions PostgreSQL with pgvector and the default Ollama model `embeddinggemma` (768 dimensions). Defaults:

| Key | Default |
| --- | --- |
| `embedding.provider` | `ollama` |
| `embedding.target` | `http://localhost:11434` |
| `embedding.model` | `embeddinggemma` |
| `embedding.dimensions` | `768` |

Switch provider with `tapes config set embedding.provider openai` and store a key via `tapes auth openai` or `OPENAI_API_KEY`. See [Configure embeddings](/configure-embeddings).
</Step>
<Step title="Serve, derive, and embed">
`tapes serve` runs proxy, read API, ingest, derive worker, and — by default — an in-process embed loop (10s interval, one pass at startup). Disable embedding with `--embed-spans=false`.
</Step>
<Step title="Point the client at the read API">

```bash
tapesctl config set tapes-url http://localhost:8081
```

Search talks to `:8081`, not ingest `:8082`.
</Step>
<Step title="Have derived spans">
Seed or capture, then confirm sessions exist (`tapesctl sessions list`) and that traces/spans have been derived.
</Step>
</Steps>

## Search with tapesctl

<CodeGroup>

```bash title="Human-readable hits"
tapesctl search "how was authentication fixed?"
tapesctl search "logging configuration" --top 10
```

```bash title="Session ids only"
tapesctl search "Charm CLI patterns" --quiet --top 3
```

</CodeGroup>

`--top` maps to `top_k` (default 5). `--quiet` is a pipe format, not a log level: one unique session id per line, in score order. Empty results are not an error: non-quiet prints `No results found.` and exits 0; quiet prints nothing and exits 0.

Quiet output composes with skill generation:

```bash
tapesctl skill generate $(tapesctl search "Charm CLI" --quiet --top 1) \
  --name charm-patterns
```

## HTTP: GET /v1/search/spans

:::endpoint GET /v1/search/spans Embed the query and return the nearest main LLM spans
OpenAPI operation `searchSpans`, tag `search`. Compiled into `GET /openapi` on `:8081`.

<ParamField query="query" type="string" required>
Search text. Empty or missing returns 400: `query parameter is required`.
</ParamField>

<ParamField query="top_k" type="integer">
Maximum hits. Default `5`. Must be a positive integer or the handler returns 400: `top_k must be a positive integer`.
</ParamField>

<ResponseField name="query" type="string">
Echo of the request query.
</ResponseField>

<ResponseField name="results" type="SpanSearchResult[]">
Hits in distance order. Each object uses the fields in [What a hit is](#what-a-hit-is).
</ResponseField>

<ResponseField name="count" type="integer">
`len(results)`.
</ResponseField>
:::

<RequestExample>

```bash
curl --get http://localhost:8081/v1/search/spans \
  --data-urlencode 'query=how was authentication fixed?' \
  --data-urlencode 'top_k=5'
```

</RequestExample>

<ResponseExample>

```json
{
  "query": "how was authentication fixed?",
  "count": 1,
  "results": [
    {
      "trace_id": "trc_req1",
      "span_id": "llm_req1",
      "session_id": "5b6f0f8e-2c3a-4ec0-9b6e-000000000001",
      "score": 0.91,
      "user_prompt": "fix the retry backoff",
      "snippet": "set max-poll-backoff to 30s",
      "model": "claude-sonnet-4-5",
      "started_at": "2026-06-01T12:00:00Z"
    }
  ]
}
```

</ResponseExample>

Errors use `{"error":"..."}`.

| Status | When | Body |
| --- | --- | --- |
| 400 | Missing `query`, or `top_k` not a positive integer | `query parameter is required` or `top_k must be a positive integer` |
| 500 | Query embed failed, or the store query failed | `failed to embed query: …` or the store error |
| 503 | Embedder or span store not wired on this process | `span search is not configured: embedder and span embedding store are required` |
| 503 | `span_embeddings` table does not exist yet | `span embeddings not initialized: run the embed pass (tapes serve embed-worker or tapes dev embed-spans)` |

A standalone `tapes serve api` constructs the store without creating the table. The process boots; search stays 503 until a writer (`tapes serve`, `tapes serve embed-worker`, or `tapes dev embed-spans`) has run `EnsureSchema`.

## MCP tool: search

Streamable HTTP MCP is at `http://localhost:8081/v1/mcp`. While search is still a core tool (slated for cassette extraction), it is registered only when both `Embedder` and `SpanSearcher` are configured. Cassette tools remain available if search is omitted.

| Field | Value |
| --- | --- |
| Name | `search` |
| Required | `query` string |
| Optional | `top_k` integer, default `5` |

The tool embeds the query and calls the same `SpanSearcher` as HTTP. Structured output matches `SpanSearchOutput` and is also returned as a JSON text block. Header-less calls use the nil-org tenant. Embed or store failures are MCP tool errors (`IsError`), including `ErrNotInitialized` when the table is missing.

```json
{
  "query": "how was logging configured?",
  "top_k": 3
}
```

## Keep the projection current

| Mode | Command | Behavior |
| --- | --- | --- |
| All-in-one | `tapes serve` | In-process embed loop, 10s interval, pass at startup (unless `--embed-spans=false`) |
| Split writer | `tapes serve embed-worker` | Own process; default interval `1m`; pass at startup; never blocks derive |
| One-shot backfill | `tapes dev embed-spans` | Walks every eligible span once |

Split deployment:

```bash
tapes serve derive-worker --postgres "$TAPES_STORAGE_POSTGRES_DSN"
tapes serve embed-worker --postgres "$TAPES_STORAGE_POSTGRES_DSN"
tapes serve api --postgres "$TAPES_STORAGE_POSTGRES_DSN"
```

Standalone embed-worker knobs include `--interval`, `--batch-size` (default page 100), `--max-text-bytes`, `--org`, and embedding provider/model/dimensions. Infrastructure failures (database or embedder unreachable) do not crash the worker: they log one line and retry with jittered exponential backoff (cap 5 minutes). Per-span failures increment the pass report (`failed`, `poisoned`, `empty`, `chunked`) and leave the span for a later run.

On `tapes serve`, embed setup failure is a warning that disables search; the rest of the stack still starts.

## Troubleshooting

1. Confirm the read API: `tapes status` and `curl -sS http://localhost:8081/ping`.
2. Confirm derived data: `tapesctl sessions list`, then traces for a session. Search cannot invent hits from `raw_turns` alone.
3. Confirm the embedder: for Ollama, `curl http://localhost:11434/api/tags` and that `embedding.model` is present.
4. Confirm `embedding.model` / `embedding.dimensions` match the table (default `embeddinggemma` @ `768`).
5. Read the 503 body: missing embedder/store vs uninitialized table are different strings.
6. In a split deploy, confirm `tapes serve embed-worker` is running. `--embed-spans=false` on `tapes serve` leaves the table uncreated until a writer runs.
7. Empty `results` with 200 means no embedded span was similar enough — not a transport error.

## Next

<CardGroup>
<Card title="Configure embeddings" href="/configure-embeddings">
Switch `embedding.provider`, set model and dimensions, store keys, run or disable the embed worker.
</Card>
<Card title="MCP" href="/mcp">
Streamable HTTP at `/v1/mcp`, cassette tools, and the legacy `search` tool.
</Card>
<Card title="Read API" href="/read-api">
Compiled `GET /openapi` on `:8081`, including `searchSpans`.
</Card>
<Card title="Split the stack" href="/split-the-stack">
Run derive-worker and embed-worker as separate processes.
</Card>
<Card title="Generate skills" href="/generate-skills">
Pipe quiet search session ids into skill generation.
</Card>
<Card title="Sessions, traces, and spans" href="/sessions-traces-spans">
How `call_kind=main` and deterministic ids relate to a search hit.
</Card>
</CardGroup>

---

## 10. Inspect and export

> List sessions, browse traces and raw_turns, enable the optional API web UI, and stream GET /v1/sessions/{id}/export as JSONL.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/10-inspect-and-export.md
- Generated: 2026-08-18T20:15:47.836Z

### Source Files

- `api/sessions_handlers.go`
- `api/trace_browse_handlers.go`
- `api/web_ui.go`
- `api/openapi_routes.go`
- `docs/data.md`
- `cmd/tapes/status/status.go`

---
title: "Inspect and export"
description: "List sessions, browse traces and raw_turns, enable the optional API web UI, and stream GET /v1/sessions/{id}/export as JSONL."
---

The read API on `:8081` is the inspect surface. `tapesctl` prints that JSON verbatim; `GET /` is an optional same-origin browser that hits the same routes; `GET /v1/sessions/{id}/export` streams the derived session → traces → spans projection as `application/x-ndjson`. Conversation content lives on traces and spans, not on `GET /v1/sessions/{id}`. Session, trace, and span IDs are UUIDs.

<Info>
`tapes` owns the database and serves the API. Listing, browsing, and exporting are client operations against a running read API. Capture and ingest stay on `:8082`.
</Info>

```mermaid
flowchart LR
  subgraph Clients
    CTL["tapesctl sessions / export"]
    UI["GET / optional web UI"]
    HTTP["curl / HTTP"]
  end
  subgraph API[":8081 read API"]
    SESS["/v1/sessions"]
    TR["/v1/sessions/{id}/traces<br/>/v1/traces/{trace_id}"]
    RAW["/v1/sessions/{id}/raw_turns"]
    EXP["/v1/sessions/{id}/export<br/>/v1/sessions/export"]
  end
  subgraph Store[Postgres]
    ROLL["sessions rollups"]
    SPAN["traces / spans / links"]
    LOG["raw_turns headers"]
  end
  CTL --> API
  UI --> SESS
  UI --> TR
  HTTP --> API
  SESS --> ROLL
  TR --> SPAN
  EXP --> SPAN
  EXP --> ROLL
  RAW --> LOG
```

## Prerequisites

<Steps>
<Step title="Start the stack">
```bash
tapes local up
tapes serve
```

Default listen addresses: proxy `:8080`, read API `:8081`, ingest `:8082`.
</Step>
<Step title="Point the client at the read API">
```bash
tapesctl config set tapes-url http://localhost:8081
```

`--tapes-url` overrides that value. If neither is set, `tapesctl` falls back to `TAPES_URL`.
</Step>
<Step title="Confirm the API is reachable">
```bash
tapes status
```

`tapes status` prints the resolved `.tapes/` directory, provider → upstream, whether `storage.postgres_dsn` is set, and the configured `client.api_target` (default `http://localhost:8081`). It probes `GET /v1/stats` with a 3s timeout. A live API prints `N sessions · M turns · $X.XXXX captured`. An unreachable target tells you to run `tapes local up` then `tapes serve`.
</Step>
</Steps>

<Note>
Use the Tapes session UUID from `tapesctl sessions list` / `GET /v1/sessions`. That is not the harness session id printed when `tapesctl start` exits. To resolve a harness id, pass `harness_session_id` (optionally with `harness_id`) on `GET /v1/sessions`.
</Note>

## List sessions

### tapesctl

```bash
tapesctl sessions list --tapes-url http://localhost:8081
tapesctl sessions list --limit 20
tapesctl sessions get <session-id>
```

Each command prints the server JSON as-is, so it composes with `jq`.

### GET /v1/sessions

:::endpoint GET /v1/sessions Cursor-paginated session list
Returns one `SessionItem` per harness session from the sessions table. Default order is `last_active` (`last_seen_at`) descending. Default `limit` is 50; the server clamps anything above 200.

Query parameters:

| Param | Type | Notes |
| --- | --- | --- |
| `limit` | integer ≥ 1 | Default 50, max 200 |
| `cursor` | string | Opaque keyset cursor from the previous page; must match the current `sort`/`direction` |
| `sort` | string | `last_active` (default), `started_at`, `turn_count`, `total_cost_usd`, `total_tokens`, `duration_ns`, `derived_status`, `auth_subject` |
| `direction` | `asc` \| `desc` | Default `desc` |
| `since` / `until` | RFC3339 | Activity window (turn started at); same window semantics as `GET /v1/stats` |
| `auth_subject` | string | Exact match on the gateway-stamped JWT subject stored at ingest. Filter only — not an identity claim |
| `harness_session_id` | string | Exact-match lookup; skips pagination. Alone, matches across harnesses (at most one row per harness) |
| `harness_id` | string | Only valid with `harness_session_id`. Alone is `400` |

Harness-filter requests reject `cursor`, `sort`, `direction`, `since`, and `until` (`400`). `limit` is ignored on that path. No match is `{ "items": [] }`, not `404`.

A `501` means the storage driver does not implement the sessions table (Postgres does).
:::

Each list item splits capture identity from the deriver rollup:

<ResponseField name="id" type="string">Tapes session UUID.</ResponseField>
<ResponseField name="harness_id" type="string">Capture harness (`claude`, `codex`, …).</ResponseField>
<ResponseField name="harness_session_id" type="string">Harness-native session id.</ResponseField>
<ResponseField name="display_title" type="string">Server-resolved label. Precedence: `display_name` → `rollup.title` → non-JSON `rollup.preview` → `name` → 12-char `harness_session_id` slice → session id. Never empty. Render this, not `name`.</ResponseField>
<ResponseField name="display_name" type="string">User rename from `PATCH /v1/sessions/{id}`. Survives ingest re-sending the harness slug.</ResponseField>
<ResponseField name="name" type="string">Harness slug / folded identity-row label. Ingest re-sends it every turn.</ResponseField>
<ResponseField name="live" type="boolean">`true` when `ended_at` is null and `last_seen_at` is within 5 minutes. Not derived from `rollup.status`.</ResponseField>
<ResponseField name="rollup" type="object">Deriver-owned status, title, preview, `turn_count`, dominant `model`, `model_usage`, `kind_counts`, `tasks`, and `usage` (`input_tokens`, `output_tokens`, `cost_usd`). Zero/empty until the session first derives.</ResponseField>
<ResponseField name="next_cursor" type="string">Present when another page exists.</ResponseField>

<RequestExample>
```bash
curl -sS 'http://localhost:8081/v1/sessions?limit=20'
curl -sS 'http://localhost:8081/v1/sessions?harness_session_id=<harness-session-id>'
```
</RequestExample>

<ResponseExample>
```json
{
  "items": [
    {
      "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
      "harness_id": "claude",
      "harness_session_id": "sess_01ABC",
      "display_title": "Retry backoff in the proxy",
      "live": false,
      "rollup": {
        "status": "completed",
        "turn_count": 4,
        "model": "claude-sonnet-4-5",
        "kind_counts": { "llm": 6, "tool": 3 },
        "tasks": [],
        "usage": { "input_tokens": 12000, "output_tokens": 800, "cost_usd": 0.042 }
      }
    }
  ],
  "next_cursor": "..."
}
```
</ResponseExample>

`GET /v1/sessions/{id}` returns `{ "session": SessionItem }` only. A malformed id is `400` (`id must be a valid UUID`); unknown id is `404`.

`PATCH /v1/sessions/{id}` updates `display_name` (max 200 characters after trim). Null or empty clears the rename so `display_title` falls back to the derived title. `DELETE /v1/sessions/{id}` removes the session and cascading derived traces/spans (including child subagent sessions) and leaves the immutable `raw_turns` log intact.

## Browse traces and spans

Initial paint is O(turns), not O(session). Load turn headers, then expand one trace, then drill into one span.

| Route | Returns |
| --- | --- |
| `GET /v1/sessions/{id}/traces` | Composite: `schema`, `session`, `traces[]` (`trace` + `spans`), session-scoped `links` |
| `GET /v1/traces?session_id=` | Turn summaries only (`TraceListResponse`). `session_id` is required and must be a UUID |
| `GET /v1/traces/{trace_id}` | One turn: spans nested by `parent_span_id`, plus links that touch this trace |
| `GET /v1/traces/{trace_id}/spans/{span_id}` | One span with full `input` / `output` payloads |

`?payload=full` (default) embeds stored content blocks. `?payload=preview` truncates payload strings to 512 runes and sets `payload` so a client can fetch the span endpoint. Anything other than `preview` is treated as full.

Trace headers carry `trace_id`, `user_prompt` (always present; empty means a synthetic opener), `response_preview`, `status`, `source` (`wire` or `transcript`), timestamps, `span_count`, `usage` (all LLM spans including shadow), `main_usage` (task slice: main + subagents), and optional `synthetic` (`post-compaction`, `shadow-opener`).

Every composite and export line stamps `"schema": "2026-06-15"` — the projection generation currently served.

<CodeGroup>
```bash title="tapesctl"
tapesctl sessions traces <session-id>
```

```bash title="curl"
curl -sS "http://localhost:8081/v1/sessions/<session-id>/traces?payload=preview"
curl -sS "http://localhost:8081/v1/traces?session_id=<session-id>"
curl -sS "http://localhost:8081/v1/traces/<trace-id>?payload=full"
curl -sS "http://localhost:8081/v1/traces/<trace-id>/spans/<span-id>"
```
</CodeGroup>

<Tip>
`GET /v1/stats` is the same accounting the session and trace views use: `session_count`, `turn_count` (traces), `completed_count`, `total_cost`, `input_tokens`, `output_tokens`, `total_duration_ms` (summed agent time, not wall-clock idle), `tool_calls`. Optional `since` / `until` / `auth_subject` match the session list window.
</Tip>

## Browse raw_turns

`GET /v1/sessions/{id}/raw_turns` is the operator wire log: one header per captured call or transcript push, keyed by the session's `(harness_id, harness_session_id)`. It does not return request/response blobs.

<ResponseField name="id" type="integer">`raw_turns` row id.</ResponseField>
<ResponseField name="source" type="string">`wire` vs `transcript`.</ResponseField>
<ResponseField name="provider" type="string">Capture provider, when known.</ResponseField>
<ResponseField name="agent_name" type="string">Agent label from the capture envelope.</ResponseField>
<ResponseField name="request_id" type="string">Capture request id, when present.</ResponseField>
<ResponseField name="received_at" type="string">Ingest timestamp.</ResponseField>
<ResponseField name="meta" type="object">Envelope metadata.</ResponseField>
<ResponseField name="request_bytes" type="integer">Captured request size.</ResponseField>
<ResponseField name="response_bytes" type="integer">Captured response size.</ResponseField>

```bash
tapesctl sessions raw-turns <session-id>
curl -sS "http://localhost:8081/v1/sessions/<session-id>/raw_turns"
```

There is no public GET for a single raw-turn payload. Use this list to correlate `span.raw_turn_id` with what crossed the wire. Attribution repair (`POST /v1/admin/raw-turns/attribution-repair`) overlays a correction without rewriting `raw_turns`.

## Optional API web UI

`api.web_ui` defaults to `false`. When enabled, the API binary serves embedded HTML at `GET /` — no frontend build, no external scripts. Disabled, `GET /` is `404`. The route is HTML, not OpenAPI surface.

<Tabs>
<Tab title="All-in-one">
```bash
tapes serve --api-web-ui
```
</Tab>
<Tab title="Standalone API">
```bash
tapes serve api --web-ui --listen :8081
```
</Tab>
<Tab title="config.toml / env">
```toml
[api]
web_ui = true
```

`TAPES_API_WEB_UI=true`. Precedence: flag → `TAPES_*` → `config.toml` → default `false`.
</Tab>
</Tabs>

Open `http://localhost:8081/`. Deep-link a session with `/?session=<uuid>`. The page:

- lists `GET /v1/sessions?limit=50`
- shows `GET /v1/stats` (`session_count`, `turn_count`, `total_cost`)
- loads `GET /v1/sessions/{id}/traces?payload=preview` and prints the selected turn header as JSON
- can `POST /v1/admin/seed/demo` (operator seed)
- links to `/swagger` and `/metrics`

It does **not** search, export, or list `raw_turns`. Use `tapesctl` or the HTTP routes for those.

<Warning>
The bundled UI is a Prometheus-style operator pane, not the product console. Prefer `display_title` and `rollup.*` in any client you write; those are the current list/detail fields.
</Warning>

## Export JSONL

`tapesctl export` is a thin client of `GET /v1/sessions/{id}/export`. It does not keep a separate renderer.

```bash
tapesctl export <session-id> --tapes-url http://localhost:8081 -o session.jsonl
tapesctl export <session-id> --detail traces
```

`-o` writes the bundle to a file and the byte count to stderr so stdout stays clean. Without `-o`, the body goes to stdout.

### GET /v1/sessions/{id}/export

:::endpoint GET /v1/sessions/{id}/export One session as a single JSONL line
`detail=spans` (default, or omitted) emits the same nested object as `GET /v1/sessions/{id}/traces?payload=full`: `{ schema, session, traces: [{ trace, spans }], links }`. Spans are loaded one trace at a time so peak memory stays at one trace, not the whole session.

`detail=traces` emits `{ schema, session, traces: [{ trace }] }` with no `spans` or `links` keys (omitted, not empty). Span-derived `tasks` / `kind_counts` are omitted at this grain.

Unknown `detail` is `400` (`detail must be spans or traces`). Missing session is `404` with no attachment headers. The handler buffers the line first, then sets:

- `Content-Type: application/x-ndjson`
- `Content-Disposition: attachment; filename="session-<id>-<YYYY-MM-DD>.jsonl"`

`detail=traces` rewrites the filename to `session-<id>-<date>-traces.jsonl`.
:::

<RequestExample>
```bash
curl -sS -D - \
  "http://localhost:8081/v1/sessions/<session-id>/export" \
  -o session.jsonl

curl -sS \
  "http://localhost:8081/v1/sessions/<session-id>/export?detail=traces" \
  -o session-traces.jsonl
```
</RequestExample>

### GET /v1/sessions/export

Bulk export of every session in a window, one JSON object per line, newest-first. Same grains as the per-session route. Not bounded by the list cap of 200 — the handler pages internally at that size and flushes after each session.

| Query | Behavior |
| --- | --- |
| `since` | RFC3339 lower bound. Default and **floor**: now − 30 days. An older `since` is clamped; you cannot stream the whole history |
| `until` | RFC3339 exclusive upper bound. Must be after the effective `since` |
| `detail` | `spans` (default) or `traces` |

Default filename: `sessions-last-30-days-<date>.jsonl`. An explicit window becomes `sessions-<since>-to-<until|now>.jsonl`. Headers are committed before the body; a mid-stream failure is logged and the stream stops — it cannot become a JSON error.

<Warning>
Register `/v1/sessions/export` before `/v1/sessions/:id`. Fiber matches in order; a parameterized route would otherwise swallow the literal `export` path.
</Warning>

A bulk export is a concatenation of per-session export lines. Reads are pinned to the deployment's single tenant; a client-asserted org header does not change the lookup.

## Errors and verification

JSON errors use `{ "error": "<message>" }`.

| Status | Typical cause |
| --- | --- |
| `400` | Malformed UUID, bad `limit`/`sort`/`direction`/`cursor`, `since`/`until` not RFC3339, harness filter combined with list options, lone `harness_id`, `detail` not `spans`/`traces`, bulk `until` not after `since` |
| `404` | Unknown session or trace/span; web UI disabled (`GET /`) |
| `500` | Storage or render failure |
| `501` | Driver does not implement sessions or the span model |

<Check>
After seed or a captured session:

1. `tapes status` shows a non-zero session count.
2. `tapesctl sessions list` returns a UUID whose `display_title` is non-empty.
3. `GET /v1/sessions/{id}/traces` has `"schema":"2026-06-15"` and one item per user-visible turn.
4. `GET /v1/sessions/{id}/export` is `application/x-ndjson` and `jq -c . session.jsonl` parses one object with `session` and `traces`.
</Check>

Empty traces usually means the session has not derived yet. `POST /v1/admin/derive/run` rebuilds the projection from `raw_turns`. Seed demo data with `tapesctl seed` or `POST /v1/admin/seed/demo`.

Browse the live contract at `http://localhost:8081/swagger` or `GET /openapi`.

## Next

<CardGroup>
<Card title="Sessions, traces, and spans" href="/sessions-traces-spans">
Deterministic IDs, rollups, span kinds, and how subagent threads rejoin.
</Card>
<Card title="Read API" href="/read-api">
Compiled GET /openapi for :8081, including CONTRACT seal rules.
</Card>
<Card title="Read API vs ingest" href="/read-vs-ingest">
Why inspect stays on :8081 and capture writes stay on :8082.
</Card>
<Card title="Search spans" href="/search-spans">
Semantic search over embedded main-conversation LLM spans.
</Card>
<Card title="CLI reference" href="/cli-reference">
tapes serve, status, and the tapes vs tapesctl split.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Wrong-port capture, 413s, attribution-repair 200 vs 202.
</Card>
</CardGroup>

---

## 11. Generate skills

> Server skill store and /v1/skills routes, transcript spine used for generation, and how tapesctl generate/list/sync talk to this API.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/11-generate-skills.md
- Generated: 2026-08-18T20:16:41.796Z

### Source Files

- `api/skills_handlers.go`
- `pkg/skill/generator.go`
- `pkg/skill/transcript.go`
- `pkg/skill/types.go`
- `pkg/skill/writer.go`
- `docs/skills.md`

---
title: "Generate skills"
description: "Server skill store and /v1/skills routes, transcript spine used for generation, and how tapesctl generate/list/sync talk to this API."
---

Skills are reusable `workflow`, `domain-knowledge`, or `prompt-template` documents extracted from derived session transcripts. The read API on `:8081` persists them in PostgreSQL under `/v1/skills`. `tapesctl skill generate` does **not** write that store: it reads the same transcript spine from `/v1/traces`, calls an extraction LLM locally, and writes `~/.tapes/skills/<name>.md`. `tapesctl skill list` and `skill sync` never call the server.

<Note>
`tapes` is the server. Skill authoring commands live in [`tapesctl`](https://github.com/papercomputeco/tapesctl). This repository owns the `/v1/skills` contract, the generator, and the `SKILL.md` renderer those commands share.
</Note>

## Two stores, one transcript

```mermaid
flowchart LR
  subgraph client ["tapesctl"]
    gen["skill generate"]
    list["skill list"]
    sync["skill sync"]
    disk["~/.tapes/skills/*.md"]
  end
  subgraph api ["Read API :8081"]
    traces["GET /v1/traces"]
    generate["POST /v1/skills/generate"]
    storeRoutes["GET/PUT/DELETE /v1/skills"]
    md["GET /v1/skills/{id}/skill.md"]
  end
  subgraph pg ["PostgreSQL"]
    skills["skills + skill_versions"]
  end
  subgraph llm ["Extraction LLM"]
    provider["openai / anthropic / ollama"]
  end
  gen --> traces
  gen --> provider
  gen --> disk
  list --> disk
  sync --> disk
  generate --> provider
  generate --> skills
  storeRoutes --> skills
  md --> skills
```

| Path | Who extracts | Where the skill lands | How you install it |
| --- | --- | --- | --- |
| Console / HTTP | Server (`POST /v1/skills/generate`) | `skills` table, keyed by opaque UUID | `GET /v1/skills/{id}/skill.md` |
| `tapesctl skill generate` | Client (`pkg/skill` + caller-supplied LLM) | `~/.tapes/skills/<name>.md` | `tapesctl skill sync` |

The two stores do not sync. A skill generated in the console does not appear in `tapesctl skill list`. A file written by `tapesctl` does not appear in `GET /v1/skills`.

## Transcript spine

`pkg/skill.BuildSessionTranscript` is the single transcript path for both the server generator and `tapesctl`. It walks the derived turn/span surface — not `raw_turns` and not a loopback HTTP self-call on the server.

For each product session (`/v1/sessions` UUID):

1. Load turn headers (`GET /v1/traces?session_id=` on the client; in-process `ListTraceSummaries` on the server).
2. Drop synthetic turns (`compaction`, resume replay). Optionally keep only turns in `--since` / `--until`.
3. For each remaining turn, emit `[user] <prompt>`, then walk that turn's spans in order.

Only the conversation spine reaches the extraction prompt:

| Span | Included? |
| --- | --- |
| `kind=llm`, `call_kind=main`, empty `thread_id`, non-empty text | `[assistant]` line |
| `kind=tool`, empty `thread_id` | Accumulated into the next `[tools] name, name ×N` line |
| `call_kind` `offshoot:…` or `injected:…` | No |
| Any span with a non-empty `thread_id` (subagent) | No |
| Thinking / non-text content blocks | No |

If a turn has no spine text, the deriver's `response_preview` stands in as `[assistant]`. Multiple sessions are joined with `\n---\n`. Combined input is capped at 30 000 characters at a session boundary; the first session is always kept even if it exceeds the cap.

```text
[user] My useEffect keeps running in an infinite loop
[assistant] Let me check the dependency array.
[tools] Read
[assistant] The issue is a new object reference on each render.
```

The extraction prompt asks for JSON only: `description`, `tags`, `content`, and (when the caller omitted a name) `name`. Up to three parse retries append “Return ONLY valid JSON”. The resulting `Skill` is always typed by the caller, versioned `0.1.0`, and stamped with every requested session ID — including sessions dropped by the char cap.

## Server skill store

Skills require the PostgreSQL driver. The in-memory node store returns **501** (`skills not supported by this backend`). Every row is scoped to the single-tenant org `00000000-0000-0000-0000-000000000000`.

Identity is an opaque UUID (`id`). `slug` is a kebab-case display label and `SKILL.md` filename derived from `name`; it is not unique and is not a route key. Content lives on the skill row. `skill_versions` is publish history only.

The wire JSON is camelCase (`sessionIds`, `isAiGenerated`, `originatingSessionIds`) — an exception to the snake_case used elsewhere on `:8081`. The list envelope still uses `next_cursor`.

### Routes

| Method | Path | Role |
| --- | --- | --- |
| `POST` | `/v1/skills/generate` | Extract from sessions and persist |
| `POST` | `/v1/skills` | Create a blank / authored skill |
| `GET` | `/v1/skills` | Keyset page + tab counts |
| `GET` | `/v1/skills/{id}` | One skill |
| `PUT` | `/v1/skills/{id}` | Partial update of the head |
| `DELETE` | `/v1/skills/{id}` | Delete skill + versions |
| `GET` | `/v1/skills/{id}/versions` | Full version history, newest first |
| `POST` | `/v1/skills/{id}/versions` | Publish an immutable snapshot |
| `POST` | `/v1/skills/{id}/duplicate` | Fork under a new id |
| `GET` | `/v1/skills/{id}/skill.md` | Download `SKILL.md` (counts a download) |
| `GET` | `/v1/sessions/{id}/skills` | Skills whose provenance includes this session |

:::endpoint POST /v1/skills/generate Extract and persist a skill
The client nominates source sessions and optional hints. The server is authoritative on the body.

The generator reads transcripts through an org-scoped in-process querier (`skillTraceQuerier`). A session UUID the tenant cannot see is **404** before any LLM call.

Honored hint fields: `name`, `type`. `hint.description` and `hint.tags` are accepted on the wire and ignored; tags and description come from the model. Empty `name` lets the model suggest a title. Default `type` is `workflow`.

Generated rows are `visibility: private`, `isAiGenerated: true`, version `0.1.0`. Author is `x-paper-auth-subject` when the gateway (or local console) stamps it.

**422** means the extraction LLM has no key: generation reuses the tenant's search/embedding credential. **500** covers LLM/parse/persist failures. OpenAPI also lists 422 for empty sources; the handler maps a missing session to **404** and other generate errors to **500**.
:::

<RequestExample>
```bash
curl -sS -X POST http://127.0.0.1:8081/v1/skills/generate \
  -H 'Content-Type: application/json' \
  -H 'x-paper-auth-subject: user_01ABC' \
  -d '{
    "sessionIds": ["11111111-1111-1111-1111-111111111111"],
    "hint": { "name": "debug-react-hooks", "type": "workflow" }
  }'
```
</RequestExample>

<ResponseExample>
```json
{
  "id": "c0ffee00-0000-4000-8000-000000000001",
  "slug": "debug-react-hooks",
  "parentId": null,
  "name": "debug-react-hooks",
  "description": "Debug React hooks infinite loops. Use when debugging useEffect.",
  "type": "workflow",
  "version": "0.1.0",
  "visibility": "private",
  "tags": ["react", "hooks"],
  "content": "## Debug React Hooks\n\n1. Check the dependency array\n",
  "isAiGenerated": true,
  "originatingSessionIds": ["11111111-1111-1111-1111-111111111111"],
  "authorId": "user_01ABC",
  "downloadCount": 0,
  "createdAt": "2026-08-18T12:00:00Z",
  "updatedAt": "2026-08-18T12:00:00Z"
}
```
</ResponseExample>

:::endpoint GET /v1/skills List skills
Keyset pagination mirrors `/v1/sessions`. Default sort is `updated_at DESC, id DESC`. `sort=downloads` switches the cursor to `download_count`. Changing sort requires dropping the cursor.

Default `limit` is 24, max 100. `q` searches name, description, and tags. `scope` is `all` (default), `mine`, or `team`, using `x-paper-auth-subject`. `counts` (`all`, `mine`, `team`) are computed over the full match, not the page.
:::

<ParamField query="limit" type="integer">Page size. Default `24`, max `100`.</ParamField>
<ParamField query="cursor" type="string">Opaque base64url JSON from the previous `next_cursor`.</ParamField>
<ParamField query="q" type="string">Search over name, description, and tags.</ParamField>
<ParamField query="scope" type="string">`all` \| `mine` \| `team`.</ParamField>
<ParamField query="sort" type="string">Omit for recency. `downloads` for most downloaded.</ParamField>

### Create, edit, publish, delete

`POST /v1/skills` is the hand-authored path: `isAiGenerated: false`, empty provenance. Missing `name` becomes `New skill`; missing `type` becomes `workflow`; version `0.1.0`, visibility `private`.

`PUT /v1/skills/{id}` is a partial update of the head. Renaming recomputes `slug`. It does not publish. `created_at` and `author_subject` stay with the original creator.

`POST /v1/skills/{id}/versions` snapshots content (body `content`, or the current head if omitted) and bumps the displayed semver: first publish `0.1.0`, then `0.1.1`, …. Concurrent publishes retry up to four times on `(skill_id, version_number)` conflict. A version that lands while the head bump fails is **500** (`version published but …`), not **201** with stale data.

`POST /v1/skills/{id}/duplicate` mints a new id, sets `parentId`, appends ` (copy)` to the display name, keeps the parent's slug, resets visibility to `private` and version to `0.1.0`, and attributes the copy to the caller.

`DELETE /v1/skills/{id}` is owner-gated: **403** if `author_subject` is set and does not match `x-paper-auth-subject`. Unattributed (empty author) skills are deletable by anyone. Success is **204**.

:::endpoint GET /v1/skills/{id}/skill.md Download a drop-in SKILL.md
Renders the same frontmatter + body as `pkg/skill.RenderSkillMD`. Frontmatter `name` is the kebab **slug**, not the human display name — Claude Code matches that string to the skill directory.

`Content-Type: text/markdown; charset=utf-8`  
`Content-Disposition: attachment; filename="<slug>.md"`

Increments `download_count` best-effort; a counter write failure does not fail the download.
:::

## Configure server-side generation

Generation reuses the search/embedding credential. The chat **model** is a separate knob because embedding models are not chat models.

| Setting | Flag / key | Used as |
| --- | --- | --- |
| Provider | `embedding.provider` (`--embedding-provider`) | `SkillLLMProvider` |
| API key | `tapes auth` / env for that provider | `SkillLLMAPIKey` |
| Base URL | `embedding.target` (`--embedding-target`) | `SkillLLMBaseURL` |
| Chat model | `--skill-model` / `skill.model` / `TAPES_SKILL_MODEL` | `SkillLLMModel` |

These fields are wired today on **`tapes serve api`**. The bundled `tapes serve` stack starts the same API process but does not copy embedding credentials onto `SkillLLM*`. In that process the handler defaults provider to `openai` and resolves the key from `OPENAI_API_KEY` (or `tapes auth` only if a caller constructed `NewLLMCaller` with a credentials manager — the HTTP handler does not).

<ParamField body="provider" type="string">`openai` (handler default when unset), `anthropic`, or `ollama`.</ParamField>
<ParamField body="model" type="string">Defaults: `gpt-4o-mini`, `claude-haiku-4-5-20251001`, `llama3.2`.</ParamField>
<ParamField body="timeout" type="duration">One 30s budget covers the HTTP call plus a single retry on 429/502/503/504.</ParamField>

Ollama needs no API key (`http://localhost:11434/api/chat`, `format=json`). OpenAI and Anthropic without a key return **422**: `skill generation requires the search/embedding feature to be enabled for this tenant`.

## tapesctl generate, list, and sync

Client commands are documented in this repo and implemented against `pkg/skill`. They talk to **different** HTTP surfaces than `/v1/skills`.

### generate

```bash
tapesctl skill generate <session-id> --name debug-react-hooks
tapesctl skill generate <session-a> <session-b> --name retry-patterns
tapesctl skill generate --search "gum glow charm" \
  --search-top 3 --name charm-cli-patterns
tapesctl skill generate <session-id> --name morning-work \
  --since 2026-02-17 --until 2026-02-17T17:00:00Z \
  --type workflow --preview
```

| Flag / input | Talks to |
| --- | --- |
| Positional session IDs | `GET /v1/traces?session_id=` then `GET /v1/traces/{trace_id}` on `--tapes-url` (read API, default `:8081`) |
| `--search` / `--search-top` | `GET /v1/search/spans`; positional IDs win if both are set |
| `--since` / `--until` | Client-side turn filter in `BuildSessionTranscript` (the server generate route does not apply a time window) |
| `--provider` / `--model` / `--api-key` | The extraction LLM — not the tapes server |
| `--preview` | Print without writing `~/.tapes/skills` |

`--tapes-url` / `TAPES_URL` is the read API that supplies transcripts. It is not ingest (`:8082`) and not `POST /v1/skills/generate`.

| Provider | Default model | Key |
| --- | --- | --- |
| `openai` (default) | `gpt-4o-mini` | `OPENAI_API_KEY` required |
| `anthropic` | `claude-haiku-4-5-20251001` | `ANTHROPIC_API_KEY` required |
| `ollama` | `llama3.2` | none |

Prefer the environment variable over `--api-key`: a flag value is visible in `ps` and shell history for the life of the process.

Compose with span search:

```bash
tapesctl skill generate $(tapesctl search "Charm CLI" --quiet --top 1) \
  --name charm-patterns
```

### list

```bash
tapesctl skill list
tapesctl skill list --type workflow
```

Reads `~/.tapes/skills/*.md`. No HTTP. `--tapes-url` may appear on `--help` because it is inherited; the command ignores it.

### sync

```bash
tapesctl skill sync debug-react-hooks
tapesctl skill sync debug-react-hooks --local             # .agents/skills/
tapesctl skill sync debug-react-hooks --claude            # ~/.claude/skills/
tapesctl skill sync debug-react-hooks --claude --local    # .claude/skills/
tapesctl skill sync debug-react-hooks --dry-run
```

Local file copy of `<name>.md`. Default target is `~/.agents/skills/`. No server involved.

To install a **server** skill into an agent directory, download `GET /v1/skills/{id}/skill.md` (frontmatter `name` = slug) and place that file yourself — `tapesctl skill sync` will not see it until a copy exists under `~/.tapes/skills/`.

## On-disk SKILL.md

```markdown
---
name: debug-react-hooks
description: Debug React hooks issues. Use when debugging useEffect loops.
version: 0.1.0
tags: [react, hooks, debugging]
type: workflow
sessions: [sess-1, sess-2]
created_at: 2026-02-17T10:00:00Z
---

## Debug React Hooks

1. Check dependency array
2. Look for stale closures
```

`Write` creates the directory at `0700`/`0755` and the file at `0600`. `List` skips unreadable or non-frontmatter files.

## Errors and verification

| Status | When |
| --- | --- |
| 400 | Empty `sessionIds`, unknown `type`, malformed cursor, invalid JSON |
| 403 | Delete by someone other than `author_subject` |
| 404 | Unknown skill id, or generate source session not in the tenant |
| 422 | Server generate: no API key for a non-Ollama provider |
| 500 | LLM call/parse/persist, or published version whose head bump failed |
| 501 | Driver is not PostgreSQL |

Valid types: `workflow`, `domain-knowledge`, `prompt-template`.

<Steps>
<Step title="Confirm the read API and a derived session">
```bash
curl -sS http://127.0.0.1:8081/ping
tapesctl sessions list --tapes-url http://localhost:8081
```
Generation needs the span projection, not raw ingest alone. See [Capture and derive](/capture-and-derive).
</Step>
<Step title="Generate locally or on the server">
```bash
tapesctl skill generate <session-id> --name debug-react-hooks --preview
# or
curl -sS -X POST http://127.0.0.1:8081/v1/skills/generate \
  -H 'Content-Type: application/json' \
  -d '{"sessionIds":["<session-id>"],"hint":{"name":"debug-react-hooks"}}'
```
</Step>
<Step title="Verify the store you used">
```bash
ls ~/.tapes/skills/debug-react-hooks.md
# or
curl -sS http://127.0.0.1:8081/v1/skills
curl -sS http://127.0.0.1:8081/v1/sessions/<session-id>/skills
```
</Step>
</Steps>

<Warning>
`POST /v1/skills/generate` on bundled `tapes serve` defaults to OpenAI unless you run `tapes serve api` (which inherits `embedding.provider`). A local Ollama embedder does not automatically make all-in-one generate call Ollama.
</Warning>

<AccordionGroup>
<Accordion title="501 on every /v1/skills route">
The API process is not using the PostgreSQL driver. Skills are unimplemented on the in-memory store.
</Accordion>
<Accordion title="422 on generate">
No chat-provider key resolved. On `tapes serve api`, store one with `tapes auth` for `embedding.provider`, or set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`. Or set `embedding.provider=ollama`.
</Accordion>
<Accordion title="404 on generate">
One of `sessionIds` is unknown or not in the single-tenant org. Confirm `GET /v1/sessions/{id}` first.
</Accordion>
<Accordion title="tapesctl list is empty after a console generate">
Expected. Download `GET /v1/skills/{id}/skill.md` or keep using the server store.
</Accordion>
<Accordion title="Generated skill is too specific">
The model is asked for the generalizable technique, not session-specific details. Preview first. Sessions that are mostly harness offshoots produce a thin spine (previews + few `[tools]` lines).
</Accordion>
</AccordionGroup>

## Next

<CardGroup>
<Card title="Search spans" href="/search-spans">
Feed `--search` / `tapesctl search --quiet` session IDs into generate.
</Card>
<Card title="Read API" href="/read-api">
Compiled `GET /openapi` for `:8081`, including the skills tag.
</Card>
<Card title="Sessions, traces, and spans" href="/sessions-traces-spans">
What `call_kind`, `thread_id`, and synthetic turns mean for the spine.
</Card>
<Card title="Configure embeddings" href="/configure-embeddings">
The credential and provider `POST /v1/skills/generate` reuses.
</Card>
<Card title="Inspect and export" href="/inspect-and-export">
Browse the same turns generate reads, or export JSONL.
</Card>
<Card title="CLI reference" href="/cli-reference">
`tapes` vs `tapesctl` ownership. Skill commands are not on this binary.
</Card>
</CardGroup>

---

## 12. Run a cassette locally

> Admit the bundled hello-world cassette with Compose, --cassettes, and tapesctl against the republished /v1/cassettes surface.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/12-run-a-cassette-locally.md
- Generated: 2026-08-18T20:15:45.393Z

### Source Files

- `pkg/cassette/examples/hello-world/compose.yaml`
- `pkg/cassette/examples/hello-world/cassette.toml`
- `pkg/cassette/examples/hello-world/main.go`
- `pkg/cassette/examples/mcp-tool/main.go`
- `docs/cassette-walkthrough.md`
- `cmd/tapes/serve/cassettes.go`

---
title: "Run a cassette locally"
description: "Admit the bundled hello-world cassette with Compose, --cassettes, and tapesctl against the republished /v1/cassettes surface."
---

Tapes never starts a cassette. The bundled example at `pkg/cassette/examples/hello-world` ships the deployment that does: Compose brings up Postgres, the cassette on `127.0.0.1:9999`, and `tapes serve api` on `127.0.0.1:8081` with `--cassettes http://hello-world:9999/openapi`. The API server fetches that document, admits the `x-tapes-cassette` manifest, republishes operations under `/v1/cassettes/hello-world`, and reverse-proxies client calls onto `/api/hello-world` on the cassette origin.

The cassette contract — manifest schema, admission rules, grants — lives on [Cassettes](/cassettes). This page is the local run path.

<Note>
The cassette surface is `cassette/v1alpha1`. Treat the current admission and proxy behavior as an experiment-grade contract, not a stability promise.
</Note>

## What Compose starts

The compose project name is `tapes-hello-world`. It runs only the read API, not `tapes serve`: cassette discovery and proxy do not go through the LLM proxy, and the example has no upstream to point that proxy at.

| Service | Image / build | Host bind | Role |
| --- | --- | --- | --- |
| `postgres` | `public.ecr.aws/g4e5l3z3/papercomputeco/postgres:17.7-pgduckdb-1.1.1` | none | Database `tapes`. `5432` stays on the compose network so it does not collide with a host tapes stack. |
| `hello-world` | build context `.` | `127.0.0.1:9999` | Cassette process. Listens on `0.0.0.0:9999` inside the container. |
| `tapes` | repo-root `Dockerfile` | `127.0.0.1:8081` | `serve api --cassettes http://hello-world:9999/openapi --cassette-refresh 10s` |

`tapes` depends only on a healthy Postgres. `hello-world` is deliberately omitted from that `depends_on` list: unresolved cassette sources are retried through a 15-second startup window (every 500ms) and then on every refresh. A cassette that is slow or restarting is an ordinary state, not a boot failure.

Postgres has no host `ports:` on purpose. Init runs `provision.sql` once, when the volume is first created.

:::files
pkg/cassette/examples/hello-world/
├── cassette.toml      # publishable manifest; tapes never reads this file
├── compose.yaml       # postgres + cassette + tapes serve api
├── Dockerfile         # cassette image, EXPOSE 9999
├── main.go            # /ping, /openapi, /api/<name>/hello
├── openapi.go         # compiled OpenAPI + x-tapes-cassette
├── provision.sql      # CREATE ROLE "cassette_hello-world"
└── go.mod             # separate module from tapes
:::

## Prerequisites

- Docker with Compose
- a checkout of this repository
- [`tapesctl`](https://github.com/papercomputeco/tapesctl) if you want generated cassette commands (`tapes` is the server; `tapesctl` is the client)

```bash
curl -sSfL https://download.tapes.dev/tapesctl/install | bash
```

<Warning>
Build the compose images for the host architecture. Do not force `--platform linux/amd64` on Apple Silicon: the Go toolchain is unreliable under QEMU and the build can crash.
</Warning>

## Start the stack

<Steps>
<Step title="Bring the example up">

```bash
cd pkg/cassette/examples/hello-world
docker compose up --build -d
```

Both images build from source. `tapes` retries the cassette OpenAPI URL through startup, so wait a few seconds after `up` returns.

</Step>
<Step title="Confirm discovery">

```bash
curl -s localhost:8081/v1/cassettes
```

An admitted hello-world cassette reports `name: hello-world`, `route_prefix: /v1/cassettes/hello-world`, `openapi_status: fresh`, and `problems: []`. If the source is still resolving, wait for the next refresh (compose uses `--cassette-refresh 10s`; the server default is `30s`).

</Step>
</Steps>

## Path rewrite

Core probes `GET /ping` and fetches `GET /openapi` on the cassette origin. Those anchors are not proxied. Every operation in the OpenAPI document must sit under the declared local prefix (`api.prefix_path = "api"`), so hello-world serves `/api/hello-world/hello`. Core strips that head and republishes the remainder under `/v1/cassettes/hello-world`.

```mermaid
sequenceDiagram
  participant Client
  participant API as tapes serve api :8081
  participant HW as hello-world :9999

  Client->>API: GET /v1/cassettes
  API-->>Client: Discovery (admitted names, problems)

  Client->>API: GET /v1/cassettes/hello-world/openapi.json
  Note over API: Cached, rewritten spec. Not proxied.

  Client->>API: POST /v1/cassettes/hello-world/hello
  API->>HW: POST /api/hello-world/hello
  Note over API,HW: X-Tapes-Cassette: hello-world
  HW-->>API: 201 row
  API-->>Client: 201 row
```

The cassette never sees `/v1/cassettes`. The proxy uses only the origin of the configured OpenAPI URL (scheme, host, port) as the reverse-proxy target, so the API must be reachable on the same origin as `/openapi`. Bodies are buffered; treat this surface as JSON request/response, not a stream.

## Verify admission

:::endpoint GET /v1/cassettes Discover admitted cassettes and rejected sources
**Port:** `8081` (read API)

**Response 200:** `Discovery`

<ResponseField name="contract_version" type="string">
Core contract this API serves. Hello-world depends on `v1`.
</ResponseField>
<ResponseField name="cassettes" type="DiscoveryEntry[]">
Admitted cassettes, sorted by name. OpenAPI documents are referenced, not inlined.
</ResponseField>
<ResponseField name="problems" type="Rejection[]">
Configured sources core refused. `subject` is the OpenAPI URL (credentials redacted). `reason` is not a parseable code.
</ResponseField>
:::

Each `cassettes[]` entry includes:

| Field | Hello-world value |
| --- | --- |
| `name` | `hello-world` |
| `version` | `0.0.1` |
| `display_name` | `Hello World` |
| `route_prefix` | `/v1/cassettes/hello-world` |
| `openapi_path` | `/v1/cassettes/hello-world/openapi.json` |
| `openapi_status` | `fresh`, `stale`, or `missing` |
| `manifest_digest` | SHA-256 of the admitted `x-tapes-cassette` manifest |
| `depends.core` | `v1` |
| `depends.views` | `[]` (this cassette reads no tapes views) |
| `tables` | `["hello-world.hello"]` (schema-qualified) |
| `config` | schema only: `greeting` string, default `Hello` |

<RequestExample>
```bash
curl -s localhost:8081/v1/cassettes | jq
```
</RequestExample>

<ResponseExample>
```json
{
  "contract_version": "v1",
  "cassettes": [
    {
      "name": "hello-world",
      "version": "0.0.1",
      "display_name": "Hello World",
      "route_prefix": "/v1/cassettes/hello-world",
      "openapi_path": "/v1/cassettes/hello-world/openapi.json",
      "openapi_status": "fresh",
      "manifest_digest": "sha256:8171d476..."
    }
  ],
  "problems": []
}
```
</ResponseExample>

`openapi_status` is `fresh` after a successful fetch, `stale` after a later refresh fails (the last admitted document is kept), and `missing` before any document has been cached. Removing the source from configuration withdraws the cassette. Do not change the manifest `name` served by an already resolved source URL; the source is pinned to its first admitted identity.

The cached per-cassette document and the origin-wide aggregate both show the rewritten paths:

```bash
curl -s localhost:8081/v1/cassettes/hello-world/openapi.json | jq '.paths | keys'
# ["/v1/cassettes/hello-world/hello"]

curl -s localhost:8081/openapi | jq '.paths | keys | map(select(startswith("/v1/cassettes")))'
# ["/v1/cassettes", "/v1/cassettes/hello-world/hello"]
```

`GET /v1/cassettes/{name}/openapi.json` is served from core's memory cache, not proxied. A cassette that is currently down still has a readable surface. The response `ETag` is the digest of the **republished** OpenAPI document (paths included). That is a different digest from `manifest_digest`. Conditional requests with matching `If-None-Match` return `304`.

## Call the republished API

<CodeGroup>
```bash Hello-world via tapes (public)
curl -s -D - -X POST localhost:8081/v1/cassettes/hello-world/hello
# HTTP/1.1 201 Created
# {"id":1,"hello":"hello","world":"world","created_at":"..."}

curl -s localhost:8081/v1/cassettes/hello-world/hello
# {"message":"Hello world","greeting":"Hello","cassette":"hello-world",
#  "store":"postgres","rows":[{"id":1,...}]}
```

```bash Cassette origin (not rewritten)
curl -s localhost:9999/ping
curl -s localhost:9999/openapi | jq '."x-tapes-cassette".cassette.name'
curl -s localhost:9999/api/hello-world/hello
```
</CodeGroup>

`POST /hello` accepts an empty body and writes `{hello: "hello", world: "world"}`. An optional JSON body may set those two fields. The handler returns **201**. `"store": "postgres"` means the compose-supplied `TAPES_DATABASE_URL` worked. `"store": "memory"` means the DSN was empty and rows live only in process memory.

```bash
HELLO_WORLD_DATABASE_URL= docker compose up --build -d
```

Override the greeting the same way: `CASSETTE_GREETING` in the cassette container, default `Hello`. Tapes publishes the config schema in discovery; it does not inject the value.

## Drive it with tapesctl

`tapesctl` reads the same `GET /v1/cassettes` surface and generates a subcommand per cassette, one method per OpenAPI `operationId` (kebab-cased). The nouns have to exist before the command line is parsed, so point discovery at the server first:

```bash
export TAPES_URL=http://localhost:8081
# equivalent: --tapes-url on any subcommand

tapesctl cassettes
tapesctl cassettes hello-world --help
```

```text
Commands:
  create-hello  Write one row to the hello table
  get-hello     Greet, and read back every stored row
```

Those names are `createHello` and `getHello` from the cached document. This `tapesctl` binary has no built-in knowledge of hello-world.

```bash
tapesctl cassettes hello-world create-hello
tapesctl cassettes hello-world get-hello
```

Each method's help names the rewritten route it calls. The discovered surface is cached per server and revalidated with `ETag`, so `--help` stays instant and works offline once seen.

## Register against a tapes you already run

Three equivalent ways to pass exact OpenAPI URLs. Precedence is flag, then `TAPES_*`, then `config.toml`.

<ParamField body="cassettes" type="string[]">
Full cassette OpenAPI URLs. Comma-separated or repeated `--cassettes`. Mapped as `TAPES_CASSETTES` and the `cassettes` array in `config.toml`.
</ParamField>

<ParamField body="cassette-refresh" type="duration">
How often to refetch cassette OpenAPI documents after the startup retry window. Default `30s`. Compose sets `10s`. `<= 0` disables the ticker after startup.
</ParamField>

<Tabs>
<Tab title="Flag">

```bash
tapes serve api --cassettes=http://127.0.0.1:9999/openapi
# or combined: tapes serve --cassettes=http://127.0.0.1:9999/openapi
```

Repeated and CSV flags replace env and file, they do not merge:

```bash
tapes serve api \
  --cassettes=http://one/openapi,http://two/openapi \
  --cassettes=http://three/openapi
```

</Tab>
<Tab title="Environment">

```bash
TAPES_CASSETTES=http://127.0.0.1:9999/openapi tapes serve api
```

CSV lists replace `config.toml`. Invalid URLs fail `Resolve` before the stack starts.

</Tab>
<Tab title="config.toml">

```toml
# .tapes/config.toml
cassettes = ["http://127.0.0.1:9999/openapi"]
```

</Tab>
</Tabs>

Each source must be an `http` or `https` URL with a host and no userinfo or fragment. Duplicates are rejected (`cassettes[1]: duplicates cassettes[0]`). A missing scheme (`--cassettes=cassette.internal/openapi`) fails with `must use the http or https scheme`. Unreachable sources are not a config error: they stay retryable and appear in `problems`.

<Note>
`cassette.toml` is for a registry or orchestrator that has to start the process. Core discovers and admits a cassette only from the OpenAPI document it fetches. The two copies share one schema (`cassette/manifest.ParseTOML` transcodes TOML to the same parser). If their digests disagree for the same installation identity, the running cassette is not the published one — core does not perform that comparison.
</Note>

## Hello-world identity and database

`name` is the installation identity. Everything else derives from it:

| Derived thing | Value |
| --- | --- |
| Public route | `/v1/cassettes/hello-world` |
| Local API prefix | `/api/hello-world` |
| Postgres schema | `"hello-world"` |
| Postgres role | `"cassette_hello-world"` |

`provision.sql` creates that role with password `cassette` and `GRANT CREATE ON DATABASE tapes`. It does not create the schema. The cassette runs its own migration at startup (`CREATE SCHEMA IF NOT EXISTS` and `CREATE TABLE IF NOT EXISTS "hello-world".hello`). Identifiers are quoted because a hyphen is legal in a cassette name and is a subtraction operator if left unquoted.

Compose default DSN (override with `HELLO_WORLD_DATABASE_URL`):

```text
postgres://cassette_hello-world:cassette@postgres:5432/tapes?sslmode=disable
```

Core does not create roles, grant views, inject credentials, or run cassette migrations. Hello-world declares `depends.views = []`, so it has no claim on `tapes_v1`.

Environment the cassette actually reads:

| Variable | Default | Meaning |
| --- | --- | --- |
| `CASSETTE_NAME` | `hello-world` | Identity, schema, and served prefix |
| `CASSETTE_LISTEN` | `0.0.0.0:9999` | Process listen address |
| `CASSETTE_GREETING` | `Hello` | Declared `config` key `greeting` |
| `TAPES_DATABASE_URL` | empty → memory store | Pre-provisioned cassette credential |

## Optional: capture beside the cassette

None of the cassette surface needs ingest or derive. The image Compose already built (`tapes-hello-world-tapes:latest`) can still run those processes on the same network and database.

```bash
docker run -d --name hw-ingest --network tapes-hello-world_default \
  -p 127.0.0.1:8082:8082 tapes-hello-world-tapes:latest \
  serve ingest --listen 0.0.0.0:8082 \
  --postgres 'postgres://tapes:tapes@postgres:5432/tapes?sslmode=disable'

docker run -d --name hw-derive --network tapes-hello-world_default \
  tapes-hello-world-tapes:latest \
  serve derive-worker \
  --postgres 'postgres://tapes:tapes@postgres:5432/tapes?sslmode=disable'
```

Point capture at ingest (`:8082`). Keep `TAPES_URL` on the read API (`:8081`) for listing.

```bash
tapesctl start --tapes-url http://localhost:8082 claude -- -p "Reply with exactly: ok"
```

The derive worker's default debounce is 20 seconds. After that window:

```bash
tapesctl sessions list
curl -s "localhost:8081/v1/sessions?limit=5" | jq '.items[].display_title'
```

## Sibling: mcp-tool without Compose

`pkg/cassette/examples/mcp-tool` is a second cassette that serves `/ping`, `/openapi`, and `POST /api/mcp-tool/ping`. The operation carries `x-tapes-mcp` and publishes as MCP tool `mcp-tool.ping`.

```bash
# in pkg/cassette/examples/mcp-tool
make run
tapes serve api --cassettes=http://127.0.0.1:9999/openapi
```

```bash
curl -X POST http://127.0.0.1:9999/api/mcp-tool/ping \
  -H 'Content-Type: application/json' \
  -d '{"ping":"ping"}'
# {"pong":"pong"}
```

An MCP client talks to tapes at `http://127.0.0.1:8081/v1/mcp`, not to the cassette port. Do not run mcp-tool and the hello-world compose cassette on host `:9999` at the same time.

## Tear it down

```bash
docker rm -f hw-ingest hw-derive   # only if you started the optional pair
docker compose down -v
```

`-v` drops the Postgres volume so `provision.sql` runs again on the next `up`. A leftover volume skips the init hook and the cassette role is missing.

## Failures

| Symptom | Cause | What to check |
| --- | --- | --- |
| `problems` lists the OpenAPI URL | Fetch, parse, or admission failed | Cassette `/openapi` returns 200, carries `x-tapes-cassette`, and every path is under `/api/<name>` |
| `openapi_status: stale` | A later refresh failed | Last admitted document is still served. Cassette down is expected; fix the origin and wait for `--cassette-refresh` |
| `404` `unknown_cassette` | Name not installed, or path not under an admitted prefix | `GET /v1/cassettes`. Names are lowercase; `Hello-World` is not `hello-world` |
| `503` `spec_unavailable` | Cassette is installed but no document has been cached yet | Wait out the 15s startup retry window |
| `502` `cassette_unavailable` | Reverse proxy could not reach the cassette origin | Process on `:9999`; compose health vs. empty `TAPES_DATABASE_URL` |
| `500` `storage unavailable` | Cassette DSN set but schema/role missing | `docker compose down -v` so `provision.sql` re-runs |
| `"store":"memory"` unexpectedly | Empty `TAPES_DATABASE_URL` / `HELLO_WORLD_DATABASE_URL` | Compose default DSN uses role `cassette_hello-world` |
| Cassette role missing after restart | Volume reused; init scripts do not re-run | `docker compose down -v` |
| `cassettes[0]: must use the http or https scheme` | Flag/env/file URL is not `http(s)` | Full URL including `/openapi` |
| Build crash on Apple Silicon | Forced `linux/amd64` under QEMU | Native compose build |
| `tapesctl cassettes` empty | Client pointed at the wrong port | `TAPES_URL=http://localhost:8081`, not ingest `:8082` |

Cassette proxy errors are JSON `{ "error": "<code>", "message": "..." }`. Branch on `error`.

## Next

<CardGroup>
<Card title="Cassettes" href="/cassettes">
Manifest kind, admission rules, prefix rewrite, and who provisions roles and grants.
</Card>
<Card title="MCP" href="/mcp">
Streamable HTTP at `/v1/mcp`, including cassette tools marked `x-tapes-mcp`.
</Card>
<Card title="Read API" href="/read-api">
Compiled `GET /openapi` on `:8081`, including the cassette proxy namespace.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Flag, `TAPES_`, and `config.toml` precedence, including the `cassettes` array.
</Card>
<Card title="Split the stack" href="/split-the-stack">
Run `api`, `ingest`, and `derive-worker` as separate processes.
</Card>
<Card title="Capture an agent" href="/capture-an-agent">
Point a provider client at capture; ingest always writes `:8082`.
</Card>
</CardGroup>

---

## 13. Configure embeddings

> Switch embedding.provider between ollama and openai, set model and dimensions, store keys with tapes auth, and run or disable the embed worker.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/13-configure-embeddings.md
- Generated: 2026-08-18T20:15:59.261Z

### Source Files

- `pkg/config/defaults.go`
- `pkg/embeddings/utils/new.go`
- `pkg/embeddings/openai/openai.go`
- `pkg/embeddings/ollama/ollama.go`
- `cmd/tapes/serve/embedworker/embedworker.go`
- `pkg/embedworker/worker.go`
- `cmd/tapes/auth/auth.go`

---
title: "Configure embeddings"
description: "Switch embedding.provider between ollama and openai, set model and dimensions, store keys with tapes auth, and run or disable the embed worker."
---

`embedding.provider` selects the backend that both writes span vectors and embeds search queries. Supported types are `ollama` and `openai`. Defaults are local Ollama (`embeddinggemma`, 768 dimensions, `http://localhost:11434`). Switching to `openai` remaps inherited local defaults to `text-embedding-3-large` at 1024 dimensions against `https://api.openai.com`. The embed worker is the only writer of `span_embeddings`; capture and derive never call a provider.

```mermaid
flowchart LR
  subgraph config [Config and secrets]
    TOML["config.toml embedding.*"]
    AUTH["credentials.toml or OPENAI_API_KEY"]
  end
  subgraph writers [Write path]
    EW["tapes serve or tapes serve embed-worker"]
    PASS["spanembed.Pass"]
    PROV["ollama /api/embed or openai /v1/embeddings"]
  end
  subgraph store [PostgreSQL]
    SPANS["spans kind=llm call_kind=main"]
    VEC["span_embeddings vector N"]
  end
  subgraph readers [Read path]
    API["GET /v1/search/spans"]
    CTL["tapesctl search"]
  end
  TOML --> EW
  AUTH --> EW
  TOML --> API
  AUTH --> API
  SPANS --> PASS
  EW --> PASS
  PASS --> PROV
  PASS --> VEC
  API --> PROV
  API --> VEC
  CTL --> API
```

<Note>
`tapes auth` keys are for server-side embedding and skill generation only. The capture proxy is transparent: agents send their own credentials, and those keys are never substituted.
</Note>

## Defaults

`tapes local up` writes Postgres and Ollama into `.tapes/config.toml` and pulls `embeddinggemma`. `tapes serve` then embeds eligible spans in-process unless you pass `--embed-spans=false`.

| Key | Ollama (built-in default) | OpenAI (after provider switch) |
| --- | --- | --- |
| `embedding.provider` | `ollama` | `openai` |
| `embedding.target` | `http://localhost:11434` | `https://api.openai.com` |
| `embedding.model` | `embeddinggemma` | `text-embedding-3-large` |
| `embedding.dimensions` | `768` | `1024` |

`ResolveEmbeddingConfig` applies those OpenAI values when the incoming target/model/dimensions still look like the local defaults. An explicit `--embedding-dimensions` (or a non-default model/target) is kept. The OpenAI client then appends `/v1` if the target URL has no `v1` path segment, so `https://api.openai.com` becomes `https://api.openai.com/v1` and POSTs `/embeddings`.

Any other `embedding.provider` string is rejected at embedder construction: `unsupported embedding provider: <name>`.

<ParamField body="embedding.provider" type="string">
`ollama` or `openai`. Flag: `--embedding-provider`. Env: `TAPES_EMBEDDING_PROVIDER`.
</ParamField>

<ParamField body="embedding.target" type="string">
Provider base URL. Ollama uses `{target}/api/embed`. OpenAI-compatible hosts use `{normalized}/embeddings`. Flag: `--embedding-target`. Env: `TAPES_EMBEDDING_TARGET`.
</ParamField>

<ParamField body="embedding.model" type="string">
Model name stored on each embedding row. Changing it re-embeds every eligible span. Flag: `--embedding-model`. Env: `TAPES_EMBEDDING_MODEL`.
</ParamField>

<ParamField body="embedding.dimensions" type="uint" required>
Must match the model's output and the existing `span_embeddings.embedding vector(N)` column. `0` is rejected. Upper bound is `16000`. Flag: `--embedding-dimensions`. Env: `TAPES_EMBEDDING_DIMENSIONS`.
</ParamField>

Only OpenAI sends `dimensions` on the request. Ollama ignores the config value at HTTP time; the worker still sizes the pgvector column from it and fail-fasts if the first vector length disagrees.

## Switch provider

Precedence for bound settings is flag, then `TAPES_…`, then `config.toml`, then built-in defaults.

<Tabs>
<Tab title="Ollama">

```bash
tapes local up
tapes config set embedding.provider ollama
tapes config set embedding.model embeddinggemma
tapes config set embedding.dimensions 768
tapes serve
```

No API key. Confirm the model is present:

```bash
curl http://localhost:11434/api/tags
```

</Tab>
<Tab title="OpenAI">

```bash
tapes auth openai
tapes config set embedding.provider openai
tapes serve
```

`OPENAI_API_KEY` wins over `credentials.toml`. If the env var is unset, `tapes` loads the stored `openai` key. Missing both fails embedder construction with `OPENAI_API_KEY is required for openai embeddings` — including `tapes serve --embed-spans=false`, because the API still embeds search queries.

Override model or shortened dimensions after the switch:

```bash
tapes config set embedding.model text-embedding-3-large
tapes config set embedding.dimensions 1024
```

Or one-shot flags:

```bash
tapes serve \
  --embedding-provider openai \
  --embedding-target https://api.openai.com \
  --embedding-model text-embedding-3-large \
  --embedding-dimensions 1024
```

</Tab>
</Tabs>

<CodeGroup>

```toml title="config.toml"
[embedding]
provider = "openai"
target = "https://api.openai.com"
model = "text-embedding-3-large"
dimensions = 1024
```

```bash title="Environment"
export TAPES_EMBEDDING_PROVIDER=openai
export TAPES_EMBEDDING_TARGET=https://api.openai.com
export TAPES_EMBEDDING_MODEL=text-embedding-3-large
export TAPES_EMBEDDING_DIMENSIONS=1024
export OPENAI_API_KEY=sk-...
```

</CodeGroup>

`embedding.target` can point at an OpenAI-compatible endpoint. The client normalizes the base URL (requires scheme and host) and adds `/v1` when that path segment is absent. Ollama stays a separate client and always posts to `/api/embed`.

<Warning>
Changing `embedding.dimensions` after `span_embeddings` exists fails schema ensure: the column cannot be resized in place. Re-embed into a new table or drop the old one. Changing `embedding.model` at the same dimension re-embeds in place (content hash plus model gate).
</Warning>

## Store keys with tapes auth

```bash
tapes auth openai
echo "$OPENAI_API_KEY" | tapes auth openai
tapes auth --list
tapes auth --remove openai
```

Keys land in `.tapes/credentials.toml` (`0600`). Do not put secrets in `config.toml`.

```toml
version = 0

[providers.openai]
api_key = "sk-..."
```

`tapes auth` accepts `openai` and `anthropic`. Only `openai` is an embedding provider. Anthropic credentials are for other server-side LLM features (skill generation), not span vectors.

Resolution used by `tapes serve`, `tapes serve embed-worker`, `tapes serve api`, and `tapes dev embed-spans`:

1. If `OPENAI_API_KEY` is set, that value is used (the stored key is ignored).
2. Otherwise the `openai` entry in `credentials.toml` is used.
3. If both are empty and `embedding.provider` is `openai`, startup fails.

## What gets embedded

The pass pages `spans` where `kind = 'llm'` and `call_kind = 'main'`. Shadow LLM calls (permission checks, title generation) and tool/event spans are excluded. Embedded text is the span's delta-only text blocks (fresh input plus response), with harness tags stripped. Tool payloads, thinking blocks, images, and re-sent history are not embedded.

Writes go to `span_embeddings` (`vector(N)` plus HNSW cosine index). Rows are keyed by `(org_id, trace_id, span_id, chunk_idx)` and gated by a SHA-256 of the rendered text plus the configured model. Oversized spans are chunked; text above `--max-text-bytes` (default 1 MiB) is recorded as `too_large` instead of being chunked. Deterministic provider rejections are recorded and not retried until content or model changes. Transient failures stay un-embedded and retry on the next pass.

The Postgres database must already have the `vector` extension. The worker does not create extensions.

## Run the embed worker

### In-process (`tapes serve`)

`tapes serve` starts an in-process embed loop on a 10s interval so local capture → search stays short. Schema or pass-construction errors log `span embedding disabled` and leave search unavailable; they do not stop proxy, ingest, API, or derive.

```bash
tapes serve
tapes serve --embed-spans=false
```

`--embed-spans` exists only on the combined `tapes serve` command (default `true`).

### Standalone (`tapes serve embed-worker`)

In a split stack the dedicated process is the single writer. It requires `--postgres` / `storage.postgres_dsn`. It runs one pass immediately, then every `--interval` (default `1m`).

```bash
tapes serve embed-worker --postgres "$TAPES_STORAGE_POSTGRES_DSN"
```

<ParamField body="--interval" type="duration">
Viper: `embed_worker.interval`. Env: `TAPES_EMBED_WORKER_INTERVAL`. Empty uses `1m`.
</ParamField>

<ParamField body="--metrics-listen" type="string">
Serves `/metrics`, `/healthz` (liveness, always 200), `/readyz` (503 until Postgres pings), and `/ping`. Empty disables the listener.
</ParamField>

<ParamField body="--wait-for-db" type="bool">
Retry unreachable Postgres at startup. Default is fail fast.
</ParamField>

<ParamField body="--batch-size" type="int">
Candidate page size (default `100` when `0`).
</ParamField>

<ParamField body="--max-text-bytes" type="int">
Per-span rendered-text cap. `0` uses 1 MiB. Negative disables the cap.
</ParamField>

<ParamField body="--org" type="uuid">
Embed only that org. Default: all orgs.
</ParamField>

Infrastructure pass failures back off exponentially from the interval, jittered, capped at 5m. SIGTERM/SIGINT drains the in-flight pass for 30s; a second signal kills immediately.

A one-shot backfill (same pass, no loop):

```bash
tapes dev embed-spans \
  --postgres "$TAPES_STORAGE_POSTGRES_DSN" \
  --embedding-provider ollama \
  --embedding-target http://localhost:11434 \
  --embedding-model embeddinggemma \
  --embedding-dimensions 768
```

Successful startup logs `span embedding enabled` (or `span embedding enabled (in-process)`) with provider, target, model, and dimensions.

## Disable embedding

| Goal | Action |
| --- | --- |
| Combined process, no background writes | `tapes serve --embed-spans=false` |
| Split stack, no writes | Do not run `tapes serve embed-worker` |
| Search still needed later | Keep the same `embedding.model` / `embedding.dimensions` when you start a writer again |
| Query embedding unused | Standalone `tapes serve api` skips the embedder when `vector_store.target` (and thus the Postgres DSN default) is unset; search then returns HTTP `503` |

Disabling the worker does not drop `span_embeddings`. Existing rows stay until a later pass prunes orphans after a re-derive.

## Verify

<Steps>
<Step title="Confirm resolved settings">
```bash
tapes config get embedding.provider
tapes config get embedding.model
tapes config get embedding.dimensions
tapes config get embedding.target
```
</Step>
<Step title="Confirm the writer is up">
Look for `span embedding enabled` or `embed worker starting`. On `--metrics-listen`, `GET /readyz` is `200` only after Postgres is reachable.
</Step>
<Step title="Confirm search can embed a query">
```bash
curl --get http://localhost:8081/v1/search/spans \
  --data-urlencode 'query=how was authentication fixed?' \
  --data-urlencode 'top_k=5'
```
An uninitialized table returns HTTP `503` with `span embeddings not initialized: run the embed pass (tapes serve embed-worker or tapes dev embed-spans)`. A missing query embedder returns `503` with `span search is not configured`. Empty hits are not an error.
</Step>
</Steps>

## Failure modes

| Symptom | Cause |
| --- | --- |
| Startup: `OPENAI_API_KEY is required for openai embeddings` | Provider is `openai` and neither `OPENAI_API_KEY` nor `tapes auth openai` is set |
| Startup: `existing table span_embeddings stores vector(X) … but Y dimensions are configured` | Dimension mismatch; drop or replace the table, then re-embed |
| Startup: `vector extension is not installed` | Provisioning did not install `pgvector` |
| Startup: `embed worker requires a postgres DSN` | Standalone worker without `--postgres` / `storage.postgres_dsn` |
| Search `503` not initialized | Writer has never successfully called `EnsureSchema` |
| Search `500` `failed to embed query` | Query-time provider error (Ollama down, OpenAI 4xx/5xx) |
| Spans never appear in search | Writer disabled; span is not `llm`/`main`; empty delta text; `too_large` / poisoned failure; model/dims mismatch |

Ollama HTTP timeout is 120s. OpenAI HTTP timeout is 60s. Per-span provider errors are counted and logged; they never abort a pass.

## Next

<CardGroup>
<Card title="Search spans" href="/search-spans">
Query the projection with GET /v1/search/spans and tapesctl search.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Dotdir resolution, TAPES_ env names, and the full config.toml key set.
</Card>
<Card title="Split the stack" href="/split-the-stack">
Run embed-worker as its own process next to derive-worker and api.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Missing OpenAI embed keys and other operator failures.
</Card>
</CardGroup>

---

## 14. CLI reference

> tapes commands and flags: init, local, serve, status, auth, config, backfill, raw equivalence, dev, and version. tapesctl is the client, not this binary.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/14-cli-reference.md
- Generated: 2026-08-18T20:16:31.580Z

### Source Files

- `cmd/tapes/tapes.go`
- `cmd/tapes/serve/serve.go`
- `cmd/tapes/config/config.go`
- `cmd/tapes/local/local.go`
- `cmd/tapes/backfill/backfill.go`
- `cmd/tapes/dev/dev.go`
- `docs/cli.md`

---
title: "CLI reference"
description: "tapes commands and flags: init, local, serve, status, auth, config, backfill, raw equivalence, dev, and version. tapesctl is the client, not this binary."
---

`tapes` is the server binary. It starts the capture proxy, read API, private ingest API, and workers; owns PostgreSQL; and carries operator tooling (`init`, `local`, `config`, `auth`, `backfill`, `raw`, `dev`). Capturing a session and reading one back belong to [`tapesctl`](https://github.com/papercomputeco/tapesctl). Run `tapes <command> --help` for the flag list that matches the installed binary.

```text
tapes                         tapesctl (separate repo)
─────────────────────         ────────────────────────
init / local / serve          start / capture / sync
status / auth / config        sessions / export / seed
backfill / raw / dev          search / skill / generate
version                       version
owns Postgres + services      HTTP client against :8081 / :8082
```

<Note>
`tapes serve` listens on `:8080` (proxy), `:8081` (read API), and `:8082` (ingest) by default. `tapesctl start` / `capture` / `sync` talk to ingest (`:8082`). List, export, search, and seed talk to the read API (`:8081`).
</Note>

## Command inventory

| Command | Role |
| --- | --- |
| `tapes init [--preset ...]` | Create a project-local `.tapes/` and write `config.toml`. |
| `tapes local [up\|status\|down]` | Docker-bootstrap PostgreSQL and Ollama. Bare `tapes local` is `up`. |
| `tapes serve` | Run proxy, read API, ingest, in-process derive worker, and optional embed worker. |
| `tapes serve proxy\|api\|ingest\|derive-worker\|embed-worker` | Run one process. |
| `tapes status` | Print resolved `.tapes/`, provider, storage, and API reachability. |
| `tapes auth` | Store OpenAI or Anthropic keys in `.tapes/credentials.toml`. |
| `tapes config get\|set\|list` | Read and write dotted keys in `config.toml`. |
| `tapes backfill wire-trace\|transcripts` | Replay capture artifacts through ingest. |
| `tapes raw equivalence` | Read-only proof that stored bytes re-reduce to the stored reduction. |
| `tapes dev` | Corpus fixtures, re-derive, embed backfill, OpenAPI compile. |
| `tapes version` | Print `Version`, `Sha`, and `Built at`. |

## Global flags

These persist on every `tapes` command.

<ParamField body="--config-dir" type="string">
Override the `.tapes/` directory. Resolution otherwise is cwd `.tapes/`, then `~/.tapes/`, then built-in defaults.
</ParamField>

<ParamField body="--log-level" type="string">
Minimum level: `debug`, `info`, `warn`, `error`. Default `info`.
</ParamField>

<ParamField body="--log-format" type="string">
`auto`, `console`, `text`, or `json`. Default `auto`.
</ParamField>

<ParamField body="--log-color" type="string">
`auto`, `always`, or `never`. Default `auto`.
</ParamField>

<ParamField body="--disable-telemetry" type="boolean">
Disable anonymous usage telemetry (`telemetry.disabled` / `TAPES_TELEMETRY_DISABLED`). Telemetry is also skipped in CI and never blocks a command if init fails.
</ParamField>

<ParamField body="--disable-update-check" type="boolean">
Hidden. Disables the 2s-capped pre-run version check (`update.disabled` / `TAPES_UPDATE_DISABLED`).
</ParamField>

`--debug` / `-d` is deprecated; use `--log-level=debug`.

Bound settings use flag → `TAPES_…` environment variable → `config.toml` → default. Dots become underscores: `proxy.listen` is `TAPES_PROXY_LISTEN`.

## `tapes init`

Creates `./.tapes/` in the current working directory and writes `config.toml`. That directory wins over `~/.tapes/` for later commands.

```bash
tapes init
tapes init --preset openai
tapes init --preset anthropic
tapes init --preset ollama
tapes init --preset https://example.com/config.toml
```

<ParamField body="--preset" type="string">
`openai`, `anthropic`, `ollama`, or an `http://` / `https://` URL of raw TOML. Empty writes `NewDefaultConfig()` (provider `ollama`, upstream `http://localhost:11434`).
</ParamField>

| Preset | `proxy.provider` | `proxy.upstream` |
| --- | --- | --- |
| `openai` | `openai` | `https://api.openai.com` |
| `anthropic` | `anthropic` | `https://api.anthropic.com` |
| `ollama` | `ollama` | `http://localhost:11434` |

Unknown names fail with `unknown preset`. A remote URL must return HTTP 200 and parse as TOML.

## `tapes local`

Requires Docker on `PATH`. Starts PostgreSQL (pgvector + pg_duckdb) and satisfies Ollama for embeddings.

| Subcommand | Action |
| --- | --- |
| `tapes local` / `tapes local up` | Create network `tapes-local`, start containers, wait for ready, persist DSN + embedding settings. |
| `tapes local status` | Show `tapes-local-postgres` / `tapes-local-ollama`, or native Ollama at `http://127.0.0.1:<port>`. |
| `tapes local down` | `docker rm -f` both containers. |
| `tapes local down --wipe` | Also delete `.tapes/postgres/` (all captured sessions). |

Persistent flags on the group:

| Flag | Default |
| --- | --- |
| `--postgres-port` | `5432` |
| `--ollama-port` | `11434` |
| `--postgres-image` | `public.ecr.aws/g4e5l3z3/papercomputeco/postgres:17.7-pgduckdb-1.1.1` |
| `--ollama-image` | `ollama/ollama:latest` |
| `--docker-ollama` | `false` — force the Ollama container even if native `ollama` exists |

Local Postgres identity is user `tapes`, password `tapes`, database `tapes`. Data lives under the resolved `.tapes/postgres` directory. Successful `up` writes:

- `storage.postgres_dsn` = `postgres://tapes:tapes@localhost:<port>/tapes?sslmode=disable`
- `vector_store.target` = same DSN
- `proxy.upstream` and `embedding.target` = the resolved Ollama URL
- `embedding.provider` = `ollama`
- `embedding.model` = `embeddinggemma`

Ollama planning: reuse a serving native install; if `ollama` is installed but not listening, print start + `ollama pull embeddinggemma:latest`; otherwise run `tapes-local-ollama` and pull that model. Native is preferred because a container on the same port can shadow loopback IPv6 (`localhost` → `::1`).

<Warning>
`--wipe` is irreversible. It removes the local Postgres data directory, not just the containers.
</Warning>

<RequestExample>
```bash
tapes local up
tapes local status
```
</RequestExample>

## `tapes serve`

Combined local stack. SIGINT / SIGTERM cancel the process; HTTP servers close on the way out.

Default listen addresses:

| Process | Address | Flag on `tapes serve` |
| --- | --- | --- |
| Capture proxy | `:8080` | `--proxy-listen` / `-p` |
| Read API | `:8081` | `--api-listen` / `-a` |
| Ingest | `:8082` | `--ingest-listen` / `-i` |

In-process workers (local convenience only):

- Derive worker at **2s** debounce (standalone default is 20s).
- Embed worker at **10s** interval when `--embed-spans` is true (standalone default is `1m`). Embedding setup failures log and disable search; they do not fail the stack.

<ParamField body="--provider" type="string">
`anthropic`, `openai`, or `ollama`. Config key `proxy.provider`. Default `ollama`.
</ParamField>

<ParamField body="--upstream" type="string">
Upstream LLM base URL (`proxy.upstream`). Default `http://localhost:11434`.
</ParamField>

<ParamField body="--postgres" type="string">
PostgreSQL DSN (`storage.postgres_dsn`). Required for a working stack.
</ParamField>

<ParamField body="--project" type="string">
Session project tag (`proxy.project`). Empty → git repo name.
</ParamField>

<ParamField body="--api-web-ui" type="boolean">
Serve the minimal browser UI at `/` on the read API (`api.web_ui`). Default `false`.
</ParamField>

<ParamField body="--embed-spans" type="boolean">
Run the in-process embed loop. Default `true`. Disable with `--embed-spans=false`.
</ParamField>

<ParamField body="--cassettes" type="string[]">
Full cassette OpenAPI URLs (comma-separated or repeated). Also `TAPES_CASSETTES` or `cassettes = [...]` in TOML. Lifecycle and credentials stay operator-owned.
</ParamField>

<ParamField body="--cassette-refresh" type="duration">
How often to refresh cassette OpenAPI documents. Default `30s`.
</ParamField>

Also accepted: `--vector-store-target`, `--embedding-provider`, `--embedding-target`, `--embedding-model`, `--embedding-dimensions`. Unset `vector_store.target` falls back to `storage.postgres_dsn`. Embedding API keys come from `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` if set, else `.tapes/credentials.toml`.

```bash
tapes serve
tapes serve --provider anthropic --upstream https://api.anthropic.com
tapes serve --api-web-ui
tapes serve --embed-spans=false
tapes serve --cassettes http://127.0.0.1:8091/openapi
```

### Split processes

Standalone subcommands use `--listen` / `-l` instead of the parent `--*-listen` names.

| Command | Listens | Extra flags |
| --- | --- | --- |
| `tapes serve proxy` | `proxy.listen` | `--upstream`, `--provider`, `--postgres`, `--project`. Embedding flags accepted, **no effect**. |
| `tapes serve api` | `api.listen` | `--web-ui`, `--postgres`, embedding, `--skill-model`, `--cassettes`, `--cassette-refresh`. |
| `tapes serve ingest` | `ingest.listen` | `--postgres`, `--project`. Embedding flags accepted, **no effect**. |
| `tapes serve derive-worker` | optional `--metrics-listen` | `--poll-interval` (5s), `--debounce` (20s), `--sweep-interval` (1h), `--sweep-window` (24h; negative = all history), `--max-derive-lag` (45s), `--wait-for-db`. |
| `tapes serve embed-worker` | optional `--metrics-listen` | `--interval` (1m), `--batch-size`, `--max-text-bytes`, `--org`, `--wait-for-db`, embedding pair. |

Derive worker: ingest marks sessions dirty; the worker polls, debounces, and re-derives one session at a time under a per-session advisory lock. Extra replicas are safe. Embed worker is a separate failure domain so a slow embedding backend cannot stall derivation.

`--metrics-listen` serves `/metrics`, `/healthz`, `/readyz`, and `/ping`. SIGTERM/SIGINT drain the in-flight pass (30s); a second signal kills immediately. `--wait-for-db` retries an unreachable DSN instead of failing fast.

## `tapes status`

Prints the resolved `.tapes/` directory, `proxy.provider` → `proxy.upstream`, whether `storage.postgres_dsn` is set, `client.api_target`, and a 3s probe of the read API stats endpoint.

```bash
tapes status
tapes status --api-target http://localhost:8081
```

Unreachable API is non-fatal: the command prints a fail mark and suggests `tapes local up` then `tapes serve`. Reachable output includes session count, turn count, and captured cost.

## `tapes auth`

Stores keys used by **server-side** embedding and skill generation. The capture proxy is transparent: agents bring their own credentials; captured calls are forwarded as sent.

Supported providers: `openai`, `anthropic`. File: `.tapes/credentials.toml`.

```bash
tapes auth openai
tapes auth anthropic
echo "$KEY" | tapes auth openai
tapes auth --list
tapes auth --remove openai
```

Interactive input is hidden. Piped stdin uses the first line. Empty keys are rejected. `sk-proj-...` OpenAI keys print a warning; the help text prefers `sk-svcacct-...` with All permissions.

`tapes auth --list` prints each stored provider and its env override (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`). If the env var is already set, `APIKeyForProvider` does not load the file.

## `tapes config`

Reads and writes dotted keys in `.tapes/config.toml`. Flags still override file values at runtime.

```bash
tapes config list
tapes config get proxy.provider
tapes config set proxy.provider anthropic
tapes config set storage.postgres_dsn 'postgres://tapes:tapes@localhost:5432/tapes?sslmode=disable'
```

`get` / `set` reject unknown keys. `list` prints every valid key; empty values show as `<not set>`.

Valid dotted keys:

`proxy.provider`, `proxy.upstream`, `proxy.listen`, `proxy.project`, `api.listen`, `api.web_ui`, `ingest.listen`, `client.proxy_target`, `client.api_target`, `vector_store.provider`, `vector_store.target`, `embedding.provider`, `embedding.target`, `embedding.model`, `embedding.dimensions`, `opencode.provider`, `opencode.model`, `logging.level`, `logging.format`, `logging.color`, `storage.postgres_dsn`, `telemetry.disabled`, `update.disabled`.

<Note>
`cassettes` is a top-level TOML array / `--cassettes` / `TAPES_CASSETTES`. It is **not** a `tapes config set` key.
</Note>

Defaults that matter at the CLI:

| Key | Default |
| --- | --- |
| `proxy.listen` | `:8080` |
| `api.listen` | `:8081` |
| `ingest.listen` | `:8082` |
| `client.api_target` | `http://localhost:8081` |
| `embedding.provider` / `model` / `dimensions` | `ollama` / `embeddinggemma` / `768` |

## `tapes backfill`

Offline replay through ingest. Writes are idempotent: raw turns dedup on capture id; re-derive rebuilds the projection.

<Warning>
Both subcommands default `--ingest-url` to `http://127.0.0.1:8090`, not the combined-stack `:8082`. Point at the ingest you actually run.
</Warning>

### `tapes backfill wire-trace`

Replay paperd `turn-*` bundles (`request.json` + `response.sse` + `meta.json`) as the envelope `tapes-extproc` would POST to `/v1/ingest`.

| Flag | Default | Required |
| --- | --- | --- |
| `--dir` | — | yes — wire-trace directory |
| `--ingest-url` | `http://127.0.0.1:8090` | no |
| `--session` | all | no — repeatable harness session ids |
| `--dry-run` | `false` | parse/reduce, skip POST |
| `--verbose` / `-v` | `false` | log each turn |

Stdout: `scanned N: posted N, raw-only N, skipped N, failed N`. Non-zero exit if any turn failed.

### `tapes backfill transcripts`

Upload Claude Code `<session>.jsonl` files plus `subagents/` (including `meta.json` fork edges) to `{ingest-url}/v1/ingest/transcript`. Transcripts supply the causal/fork skeleton; wire capture supplies the call inventory.

| Flag | Default |
| --- | --- |
| `--dir` | required — Claude Code project directory |
| `--ingest-url` | `http://127.0.0.1:8090` |
| `--session` | all sessions in the directory |
| `--verbose` / `-v` | `false` |

Stdout: `sessions N, files N: uploaded N, deduped N, failed N`. Unchanged files dedup; grown transcripts append a new version.

## `tapes raw equivalence`

Read-only comparison of stored `raw_response` bytes against a server-side re-reduction of those bytes (`ingest.ReduceStoredRawTurn`). Used to prove the capture ratchet step from `dual` to `raw`.

The pool sets `default_transaction_read_only=on`, uses one connection, and **does not** run migrations. Only `source = 'wire'` rows are scanned. Attribution-repair joins are not applied. Response bodies are never printed — diffs are JSON paths plus value shapes.

```bash
tapes raw equivalence --since 24h --limit 5000
tapes raw equivalence \
  --postgres "postgres://user:pass@127.0.0.1:15432/tapes" \
  --since 24h --json
```

| Flag | Default |
| --- | --- |
| `--postgres` | `storage.postgres_dsn` (required) |
| `--since` | `24h` — Go duration or RFC 3339 instant |
| `--limit` | `1000` — newest first |
| `--session` | all harness session ids |
| `--json` | text report |
| `--max-diffs` | `10` per divergent turn |
| `--max-report` | `20` detailed blocking turns (counts stay exact) |

Missing DSN: `equivalence requires a postgres DSN`. Any blocking class exits non-zero (`raw-response equivalence failed`).

| Class | Blocks ratchet? |
| --- | --- |
| `equivalent` | no — supports the flip |
| `divergent` | yes |
| `undecodable` | yes — bytes would yield no reduction |
| `unreducible` | yes |
| `no_reducer` | yes |
| `skipped_no_raw` | no |
| `skipped_dropped` | no — bytes withheld or over limit |
| `skipped_no_reduction` | no — already raw-only |

Excluded from comparison (always reported):

- `created_at` — stamped at reduction time
- `usage.total_duration_ns` — wall clock only the live stream observer can measure

A window can be fully `equivalent` and still lose duration on the flip if stamp coverage for `usage.total_duration_ns` is `fallback` (no usable `meta.elapsed_seconds`). Check that line before ratcheting.

## `tapes dev`

Maintenance utilities. Not everyday operator workflow.

| Command | Touches DB? | Purpose |
| --- | --- | --- |
| `tapes dev trace-fixtures` | no | Replay gzipped JSONL corpus through the real deriver + API renderers. `--corpus` (repeatable), `--out` directory. Writes `session-traces-<s>.json`, `.slim.json`, `trace-summaries-<s>.json`, `trace-details-<s>.slim.json`. |
| `tapes dev dump-corpus` | yes | Inverse: export `raw_turns` to corpus files. Exactly one of `--session` (file `--out`) or `--all` (directory `--out`). `--org`, `--page-size` (500). |
| `tapes dev rederive` | yes | Same whole-project rebuild as `POST /v1/admin/derive/run`, as a direct DB call. `--postgres` required; `--project` optional. Session identity rows are ingest-written and skipped. |
| `tapes dev embed-spans` | yes | One-shot embed of eligible main-conversation `llm` spans. `--org`, `--batch-size`, embedding flags. Idempotent (span id + content hash). |
| `tapes dev openapi [api\|ingest]` | no | Compile the published contract with per-field Go doc comments. `--format yaml\|json`, `--docs-root` (default `.`; empty skips comments), `--out`. Default surface is `api`. |
| `tapes dev check-invariants <path>...` | no | Structural invariants on composite session-traces JSON. |
| `tapes dev check-openapi <path>...` | no | Type-check the same wire against compiled `SessionTracesResponse`. |

```bash
tapes dev openapi
tapes dev openapi ingest --format json --out ingest-contract.json
tapes dev rederive --postgres "$DSN"
tapes dev embed-spans --postgres "$DSN" \
  --embedding-provider ollama \
  --embedding-target http://localhost:11434 \
  --embedding-model embeddinggemma \
  --embedding-dimensions 768
```

`trace-fixtures` does not need Postgres. `dump-corpus`, `rederive`, and `embed-spans` require `--postgres` or `storage.postgres_dsn`.

## `tapes version`

```text
Version: <utils.Version>
Sha: <utils.Sha>
Built at: <utils.Buildtime>
```

Used as the install smoke test after `curl -fsSL https://download.tapes.dev/install | bash`.

## Client commands live in tapesctl

These commands are **not** on `tapes`:

| Moved to tapesctl | Removed entirely |
| --- | --- |
| `start`, `capture`, `export`, `seed`, `sessions`, `search`, `skill` | `tapes chat`, `tapes checkout` |

Span search remains server-side (`GET /v1/search/spans` and the embed worker). Only the client command moved.

```bash
curl -sSfL https://download.tapes.dev/tapesctl/install | bash
tapesctl start claude --tapes-url http://localhost:8082
tapesctl sessions list --tapes-url http://localhost:8081
tapesctl export <session-id> --detail spans -o session.jsonl
tapesctl seed --tapes-url http://localhost:8081
```

`tapesctl --tapes-url` falls back to `TAPES_URL`, then `tapesctl config set tapes-url`. Capture commands use ingest; read commands use the read API.

## Failure modes

| Symptom | Cause |
| --- | --- |
| `docker is required for 'tapes local'` | Docker not on `PATH`. |
| `tapes status` API unreachable | Nothing listening on `client.api_target` (default `:8081`). |
| Backfill posts to the wrong port | `--ingest-url` defaults to `:8090`; combined `tapes serve` ingest is `:8082`. |
| `equivalence requires a postgres DSN` | No `--postgres` and no `storage.postgres_dsn`. |
| `unknown config key` | Key not in the valid set; `cassettes` cannot be `config set`. |
| `unknown preset` | Not `openai`, `anthropic`, `ollama`, or an `http(s)` URL. |
| `tapes local down --wipe` emptied sessions | Postgres data dir deleted. |
| OpenAI embed / skill calls fail with a project key | Store a service-account key via `tapes auth openai`. |
| Combined serve search empty | `--embed-spans=false`, or embed schema/provider setup logged a warning. |
| `--debug` ignored in favor of `--log-level` | Deprecated; only applies if `--log-level` was not set. |

## Related pages

<CardGroup>
  <Card title="Configuration reference" href="/configuration-reference">
    Dotdir resolution, TAPES_ env names, credentials.toml, and the cassettes array.
  </Card>
  <Card title="Split the stack" href="/split-the-stack">
    Run proxy, api, ingest, derive-worker, and embed-worker as separate processes.
  </Card>
  <Card title="Prove the capture ratchet" href="/capture-ratchet">
    off / dual / raw modes and how equivalence classes gate the flip.
  </Card>
  <Card title="Read API vs ingest" href="/read-vs-ingest">
    Why :8081 is public read and :8082 is the private write path.
  </Card>
  <Card title="Configure embeddings" href="/configure-embeddings">
    ollama vs openai, tapes auth, and the embed worker.
  </Card>
  <Card title="Capture an agent" href="/capture-an-agent">
    Point an agent at the proxy or ingest via tapesctl.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    Wrong-port capture, --wipe data loss, missing embed keys.
  </Card>
</CardGroup>

---

## 15. Configuration reference

> Dotdir resolution, flag then TAPES_ then config.toml precedence, dotted keys, defaults, credentials.toml, and the cassettes array.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/15-configuration-reference.md
- Generated: 2026-08-18T20:16:30.871Z

### Source Files

- `pkg/config/types.go`
- `pkg/config/defaults.go`
- `pkg/config/flags.go`
- `pkg/config/viper.go`
- `pkg/dotdir/manager.go`
- `cmd/tapes/config/set.go`
- `docs/configuration.md`

---
title: "Configuration reference"
description: "Dotdir resolution, flag then TAPES_ then config.toml precedence, dotted keys, defaults, credentials.toml, and the cassettes array."
---

`tapes` is the server binary. It loads persistent settings from `config.toml` in a resolved `.tapes/` directory, then overlays `TAPES_*` environment variables and bound CLI flags. Secrets belong in `credentials.toml` (or the provider’s own environment variable), not in `config.toml`. `tapesctl` is a separate client and does not share this precedence chain.

## Dotdir resolution

The server looks for a `.tapes/` directory in this order:

1. `--config-dir <directory>` (creates the directory at `0755` if missing)
2. `./.tapes/` in the current working directory
3. `~/.tapes/`
4. no directory — built-in defaults apply; `tapes config set` cannot save until a directory exists

A project-local `./.tapes/` wins over `~/.tapes/`. That is the usual reason a global setting appears ignored.

:::files
.tapes/
  config.toml         # 0600; server settings
  credentials.toml    # 0600; created by tapes auth
  telemetry.json      # optional CLI telemetry identity
  postgres/           # local Postgres data from tapes local up
:::

<ParamField body="config-dir" type="string">
Persistent flag on every `tapes` command. Absolute path that replaces the usual `./.tapes/` then `~/.tapes/` walk. Empty means walk the default order.
</ParamField>

Create a project-local directory:

```bash
tapes init
tapes init --preset anthropic
tapes init --preset openai
tapes init --preset ollama
tapes init --preset https://example.com/config.toml
```

`tapes init` always creates `./.tapes/` in the current working directory and writes `config.toml` (`0600`). `--preset` accepts `openai`, `anthropic`, `ollama`, or an `http://` / `https://` URL that returns raw TOML (`version` must be `0` or omitted). `tapes local up` also writes the resolved local Postgres DSN and Ollama embedding target into the same resolved `.tapes/config.toml`.

## Precedence

For every setting bound into Viper (serve flags, `tapes config get`/`list`, logging, telemetry, update checks):

1. CLI flag, if the flag was registered on that command and changed
2. `TAPES_*` environment variable
3. `config.toml` value
4. `NewDefaultConfig()` built-in default

Dots become underscores after the `TAPES_` prefix:

| Config key | Environment variable | Typical flag |
| --- | --- | --- |
| `proxy.listen` | `TAPES_PROXY_LISTEN` | `--proxy-listen` / `-p` |
| `proxy.provider` | `TAPES_PROXY_PROVIDER` | `--provider` |
| `proxy.upstream` | `TAPES_PROXY_UPSTREAM` | `--upstream` / `-u` |
| `storage.postgres_dsn` | `TAPES_STORAGE_POSTGRES_DSN` | `--postgres` |
| `api.listen` | `TAPES_API_LISTEN` | `--api-listen` / `-a` |
| `ingest.listen` | `TAPES_INGEST_LISTEN` | `--ingest-listen` / `-i` |
| `embedding.provider` | `TAPES_EMBEDDING_PROVIDER` | `--embedding-provider` |
| `cassettes` | `TAPES_CASSETTES` | `--cassettes` |
| `telemetry.disabled` | `TAPES_TELEMETRY_DISABLED` | `--disable-telemetry` |
| `update.disabled` | `TAPES_UPDATE_DISABLED` | `--disable-update-check` (hidden) |
| `logging.level` | `TAPES_LOGGING_LEVEL` | `--log-level` |

<CodeGroup>

```bash title="Flag"
tapes serve --provider anthropic --upstream https://api.anthropic.com
```

```bash title="Environment"
export TAPES_PROXY_PROVIDER=anthropic
export TAPES_PROXY_UPSTREAM=https://api.anthropic.com
tapes serve
```

```toml title="config.toml"
[proxy]
provider = "anthropic"
upstream = "https://api.anthropic.com"
```

</CodeGroup>

`tapes config get` and `tapes config list` bind the same `TAPES_` prefix, so they show the environment overlay, not only the file. `tapes config set` writes `config.toml` only.

Some serve knobs are **flag-only** (not Viper keys, not `tapes config set`):

| Flag | Default | Effect |
| --- | --- | --- |
| `--embed-spans` | `true` | In-process embed worker on `tapes serve`; disable with `--embed-spans=false` |
| `--cassette-refresh` | `30s` | How often the API re-fetches cassette OpenAPI documents |

## Manage dotted keys

```bash
tapes config list
tapes config get proxy.provider
tapes config set proxy.provider anthropic
tapes config set embedding.dimensions 768
```

Keys use dotted notation that matches TOML sections. Flat names (`provider`, `embedding_dimensions`) are rejected. Unknown keys fail with `unknown config key` and the valid-key list.

`tapes config set` preserves `version` and the top-level `cassettes` array. It cannot create or edit `cassettes`. Values are written through Viper so strings coerce (for example `embedding.dimensions` to `uint`). `config.toml` is rewritten at `0600`. Empty values print as `<not set>`.

## Key catalog

Defaults come from `NewDefaultConfig()`. `storage.postgres_dsn`, `proxy.project`, `vector_store.target`, OpenCode, telemetry, and update fields start empty / false.

### Storage and listen addresses

| Key | Default | Purpose |
| --- | --- | --- |
| `storage.postgres_dsn` | unset | Capture and derived PostgreSQL DSN. Required by `tapes serve derive-worker` and `tapes serve embed-worker`. `tapes local up` writes `postgres://tapes:tapes@localhost:5432/tapes?sslmode=disable` (port follows `--postgres-port`) |
| `proxy.listen` | `:8080` | Capture proxy listen address |
| `api.listen` | `:8081` | Read API listen address |
| `api.web_ui` | `false` | Minimal browser UI at `/` on the read API |
| `ingest.listen` | `:8082` | Private ingest listen address |
| `proxy.project` | unset | Session project tag. When empty at serve/derive time, the git repo name is used |
| `vector_store.target` | unset | pgvector DSN. When empty at serve time, copied from `storage.postgres_dsn` |
| `vector_store.provider` | unset | Accepted by `tapes config get`/`set`/`list`. Not a field on the persisted `Config` struct and not read by `tapes serve` |

Logged DSNs mask URL user passwords and `password=` parameters as `xxxxx`.

### Proxy and client URLs

| Key | Default | Purpose |
| --- | --- | --- |
| `proxy.provider` | `ollama` | `anthropic`, `openai`, or `ollama` |
| `proxy.upstream` | `http://localhost:11434` | Upstream provider base URL |
| `client.proxy_target` | `http://localhost:8080` | Persisted proxy URL (full `scheme://host:port`) |
| `client.api_target` | `http://localhost:8081` | Persisted read API URL. `tapes status --api-target` binds this key |

Capture and read commands that used these keys have moved to `tapesctl`. See [CLI reference](/cli-reference).

### Embeddings

| Key | Default | Purpose |
| --- | --- | --- |
| `embedding.provider` | `ollama` | `ollama` or `openai` |
| `embedding.target` | `http://localhost:11434` | Embedding service URL |
| `embedding.model` | `embeddinggemma` | Embedding model |
| `embedding.dimensions` | `768` | Vector width. Must match the model; an existing table with a different width fails embed-worker startup |

After flags, env, and file merge, `ResolveEmbeddingConfig` rewrites inherited Ollama-shaped values when `embedding.provider` is `openai`:

| Provider | Target | Model | Dimensions |
| --- | --- | --- | --- |
| `ollama` (empty fields filled) | `http://localhost:11434` | `embeddinggemma` | `768` |
| `openai` (if those fields were still the Ollama defaults) | `https://api.openai.com` | `text-embedding-3-large` | `1024` |

Explicit target, model, or dimensions are kept. OpenAI keys come from `OPENAI_API_KEY` or `credentials.toml`, never from `config.toml`. See [Configure embeddings](/configure-embeddings).

### OpenCode, logging, telemetry, updates

| Key | Default | Allowed values |
| --- | --- | --- |
| `opencode.provider` | unset | Saved OpenCode provider |
| `opencode.model` | unset | Saved OpenCode model |
| `logging.level` | `info` | `debug`, `info`, `warn`, `error` |
| `logging.format` | `auto` | `auto`, `console`, `text`, `json` |
| `logging.color` | `auto` | `auto`, `always`, `never` |
| `telemetry.disabled` | `false` | Also skipped in CI (`CI`, `GITHUB_ACTIONS`, and similar) |
| `update.disabled` | `false` | Disables the startup update check |

`--debug` is deprecated; use `--log-level=debug`. Invalid logging values fail config load and `tapes config set` with `invalid logging config`.

`version` in TOML must be `0` or omitted. Any other version is `unsupported config version`.

## Cassettes array

`cassettes` is a **top-level string array** of exact OpenAPI document URLs for already-running cassette processes. Tapes does not start those processes.

```toml
# .tapes/config.toml
version = 0
cassettes = ["http://127.0.0.1:9999/openapi"]
```

Equivalent overlays:

```bash
tapes serve --cassettes=http://127.0.0.1:9999/openapi
tapes serve api --cassettes=http://127.0.0.1:9999/openapi
TAPES_CASSETTES=http://127.0.0.1:9999/openapi tapes serve
```

`--cassettes` is repeatable. `TAPES_CASSETTES` is CSV (quoted commas allowed). Resolution is flag, then `TAPES_CASSETTES`, then the TOML array.

<Warning>
`cassettes` is not a dotted `tapes config set` key. `SetConfigValue` preserves an existing array and will not write one.
</Warning>

Each entry must:

- use `http` or `https`
- include a host (`http://:8080/openapi` is rejected)
- omit URL userinfo
- omit a fragment
- be unique (duplicates report `cassettes[N]: duplicates cassettes[M]`)

Query strings are allowed. The removed `[[cassettes]]` table shape (`manifest`, `url`, `enabled`) is rejected. Unknown fields under `cassettes` fail parse. Unreachable sources stay retryable at runtime and do not block process start. Refresh interval is `--cassette-refresh` (default `30s`). See [Cassettes](/cassettes) and [Run a cassette locally](/run-a-cassette).

## Credentials

Store provider keys with `tapes auth`. The capture proxy is transparent: it forwards whatever the agent sent. These keys are for **server-side** embedding and skill generation.

```bash
tapes auth openai
tapes auth anthropic
echo "$KEY" | tapes auth openai
tapes auth --list
tapes auth --remove openai
```

Supported providers and env overrides:

| Provider | File field | Environment (wins if set) |
| --- | --- | --- |
| `openai` | `[providers.openai] api_key` | `OPENAI_API_KEY` |
| `anthropic` | `[providers.anthropic] api_key` | `ANTHROPIC_API_KEY` |

`credentials.toml` lives next to `config.toml` in the resolved `.tapes/` directory (`0600`). If no `.tapes/` exists, `tapes auth` creates `~/.tapes/`. When the matching environment variable is already set, the file key is ignored.

<Warning>
Do not put API keys in `config.toml`. Cassette URLs must not include userinfo either.
</Warning>

For OpenAI, a service account key (`sk-svcacct-...`) is recommended. `tapes auth` warns on `sk-proj-...` prefixes.

## Init presets

| Preset | `proxy.provider` | `proxy.upstream` | Embedding |
| --- | --- | --- | --- |
| `ollama` | `ollama` | `http://localhost:11434` | Ollama defaults (`embeddinggemma` / `768`) |
| `openai` | `openai` | `https://api.openai.com` | OpenAI defaults (`text-embedding-3-large` / `1024`) |
| `anthropic` | `anthropic` | `https://api.anthropic.com` | Left at package defaults until you set `embedding.*` |

Listen addresses stay `:8080` / `:8081`. Client targets stay `http://localhost:8080` and `http://localhost:8081`.

## Worker and API-only keys

These participate in flag → `TAPES_` → TOML when the matching command binds them. They are **not** `tapes config set` keys.

### `tapes serve derive-worker`

Empty duration flags fall through to package defaults.

| Flag | Viper / env key | Default |
| --- | --- | --- |
| `--poll-interval` | `derive_worker.poll_interval` / `TAPES_DERIVE_WORKER_POLL_INTERVAL` | `5s` |
| `--debounce` | `derive_worker.debounce` | `20s` |
| `--sweep-interval` | `derive_worker.sweep_interval` | `1h` |
| `--sweep-window` | `derive_worker.sweep_window` | `24h` (negative sweeps all history) |
| `--max-derive-lag` | `derive_worker.max_derive_lag` | `45s` |
| `--metrics-listen` | `derive_worker.metrics_listen` | empty (disabled) |
| `--wait-for-db` | `derive_worker.wait_for_db` | `false` (fail fast) |

### `tapes serve embed-worker`

| Flag | Viper / env key | Default |
| --- | --- | --- |
| `--interval` | `embed_worker.interval` | `1m` |
| `--metrics-listen` | `embed_worker.metrics_listen` | empty |
| `--wait-for-db` | `embed_worker.wait_for_db` | `false` |
| `--batch-size` | `embed_worker.batch_size` | `0` (built-in page size) |
| `--max-text-bytes` | `embed_worker.max_text_bytes` | `0` (built-in cap; negative disables) |
| `--org` | `embed_worker.org` | empty (all orgs) |

### `tapes serve api`

<ParamField body="skill-model" type="string">
Chat model for `POST /v1/skills/generate`. Viper key `skill.model` / `TAPES_SKILL_MODEL`. Empty keeps the generator’s per-provider chat default. Not an embedding model.
</ParamField>

Standalone subcommands remap listen flags: `tapes serve api --listen`, `tapes serve proxy --listen`, `tapes serve ingest --listen` still bind `api.listen`, `proxy.listen`, and `ingest.listen`. Split-process flags and DSNs: [Split the stack](/split-the-stack).

## Example `config.toml`

```toml
version = 0

cassettes = ["http://127.0.0.1:9999/openapi"]

[storage]
postgres_dsn = "postgres://tapes:tapes@localhost:5432/tapes?sslmode=disable"

[proxy]
provider = "ollama"
upstream = "http://localhost:11434"
listen = ":8080"

[api]
listen = ":8081"
web_ui = false

[ingest]
listen = ":8082"

[client]
proxy_target = "http://localhost:8080"
api_target = "http://localhost:8081"

[vector_store]
target = "postgres://tapes:tapes@localhost:5432/tapes?sslmode=disable"

[embedding]
provider = "ollama"
target = "http://localhost:11434"
model = "embeddinggemma"
dimensions = 768

[logging]
level = "info"
format = "auto"
color = "auto"

[telemetry]
disabled = false

[update]
disabled = false
```

## The client file (`tapesctl`)

`tapesctl` does not read the server’s project-local `.tapes/` or this precedence chain. It keeps one file, `~/.tapes/config.toml`, and one key:

```toml
# ~/.tapes/config.toml
tapes-url = "http://localhost:8081"
```

```bash
tapesctl config set tapes-url http://localhost:8081
tapesctl config get tapes-url
tapesctl config path
```

Precedence is `--tapes-url`, then `TAPES_URL`, then that file. With none of the three, a command that needs a server fails and names the three sources. Capture commands want ingest on `:8082`; read commands want the API on `:8081` — configure one URL and pass `--tapes-url` for the other. See [Read API vs ingest](/read-vs-ingest).

## Errors and checks

| Symptom | Cause |
| --- | --- |
| Expected `~/.tapes` value ignored | `./.tapes/` exists in the current directory |
| `unknown config key` | Key not in the dotted catalog; `cassettes` cannot be `config set` |
| `cannot save empty target path` | No `.tapes/` resolved; run `tapes init` or pass `--config-dir` |
| `unsupported config version` | `version` is not `0` |
| `unknown cassette config fields` / URL validation | Bad `cassettes` entries (scheme, host, userinfo, fragment, duplicates, old table shape) |
| `parsing TAPES_CASSETTES as CSV` | Malformed CSV in the env overlay |
| `invalid logging config` | Level, format, or color not in the allowed sets |
| Derive/embed worker: `requires a postgres DSN` | Empty `storage.postgres_dsn` and no `--postgres` |
| OpenAI embed/skill calls fail with no key | Neither `OPENAI_API_KEY` nor `tapes auth openai` |

Verify the active file and overlay:

```bash
tapes config list
tapes status
```

<Check>
`tapes config list` prints `Config file: …/config.toml` when a directory resolved, or `No config file found. Using defaults.`
</Check>

## Related pages

<CardGroup>
  <Card title="CLI reference" href="/cli-reference">
    Command and flag inventory for `tapes`. `tapesctl` is the client.
  </Card>
  <Card title="Configure embeddings" href="/configure-embeddings">
    Switch `embedding.provider`, store keys with `tapes auth`, run or disable the embed worker.
  </Card>
  <Card title="Cassettes" href="/cassettes">
    Admission, path rewrite under `/v1/cassettes/{name}`, operator-owned lifecycle.
  </Card>
  <Card title="Split the stack" href="/split-the-stack">
    Per-process flags, DSNs, and failure domains.
  </Card>
  <Card title="Installation" href="/installation">
    Bootstrap local PostgreSQL and Ollama; `tapes local` writes the DSN into config.
  </Card>
</CardGroup>

---

## 16. Read API

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

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/16-read-api.md
- Generated: 2026-08-18T20:19:37.384Z

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

---

## 17. Ingest API

> Private write contract on :8082: POST /v1/ingest, POST /v1/ingest/transcript, body cap, JSON error envelope, and 413 reject_oversize.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/17-ingest-api.md
- Generated: 2026-08-18T20:18:05.686Z

### Source Files

- `ingest/openapi.go`
- `ingest/ingest.go`
- `ingest/config.go`
- `ingest/responses.go`
- `ingest/CONTRACT`
- `e2e/hurls/ingest/ingest.hurl`
- `docs/apis.md`

---
title: "Ingest API"
description: "Private write contract on :8082: POST /v1/ingest, POST /v1/ingest/transcript, body cap, JSON error envelope, and 413 reject_oversize."
---

The ingest server is a private Fiber write surface on `ingest.listen` (`:8082` by default). Capture adapters — `tapes-extproc`, `tapesctl`, and `paperd` — POST completed LLM turns and harness transcripts here. The process appends to immutable `raw_turns` and queues derivation. It does not serve the read API, and it does not write embeddings (those belong to `tapes serve embed-worker`).

<Warning>
This surface is **not internet-facing**. It trusts identity in the request envelope and optional gateway headers. Exposing it through an edge gateway would let any caller write turns under an arbitrary session. Network policy, TLS, and who may reach `:8082` are deployment responsibilities.
</Warning>

## Listen and run

`tapes serve` starts ingest next to the proxy (`:8080`) and read API (`:8081`). Sidecar / gateway capture runs the process alone:

```bash
tapes serve ingest --postgres "$TAPES_STORAGE_POSTGRES_DSN"
```

| Key / flag | Default | Role |
| --- | --- | --- |
| `ingest.listen` | `:8082` | Listen address |
| `--listen` / `-l` | same | Standalone `tapes serve ingest` flag (`FlagIngestListenStandalone`) |
| `--ingest-listen` / `-i` | same | All-in-one `tapes serve` flag |
| `--postgres` | `storage.postgres_dsn` | Required for the raw-turn layer |
| `--project` | git repo name | Tag written onto worker jobs |

Embedding flags on `tapes serve ingest` are accepted for deploy compatibility and have **no effect**. Point capture clients at ingest, not the read API:

```bash
tapesctl start claude --tapes-url http://localhost:8082
```

## Published contract

The write contract is a **separate** OpenAPI document from the read API. Both servers compile their own route table and serve it at `GET /openapi`. There is no checked-in spec file.

| Surface | Port | Title | Seal file |
| --- | --- | --- | --- |
| Read API | `:8081` | public query/export | `api/CONTRACT` |
| Ingest API | `:8082` | `Tapes Ingest API` `1.0` | `ingest/CONTRACT` |

`ingest/CONTRACT` stores `CompiledDoc.Fingerprint()` — SHA-256 of the prose-stripped compiled JSON (the same bytes as `tapes dev openapi ingest --docs-root ''`). Route, schema, required-field, and status-code changes fail `ingest/openapi_seal_test.go` until the seal is bumped. Editing a Go doc comment is not a contract event.

A running binary reflects Go types into the document, so shapes and required fields are exact. Per-field prose is present only when the binary was compiled with a source tree (`tapes dev openapi ingest`). `GET /openapi` and `GET /metrics` are **not** listed in the document: the former is circular, the latter is Prometheus scrape-by-convention.

## Endpoints

| Method | Path | In published contract | Purpose |
| --- | --- | --- | --- |
| `GET` | `/ping` | yes | Process liveness. `200` does **not** mean a write would succeed. |
| `POST` | `/v1/ingest` | yes | Append one completed LLM turn. |
| `POST` | `/v1/ingest/transcript` | yes | Append one harness transcript (or Codex spawn/re-entry anchor). |
| `GET` | `/openapi` | no | Compiled ingest contract (JSON). |
| `GET` | `/metrics` | no | Prometheus registry for this process. |

There is no `POST /v1/ingest/batch` on this server.

```mermaid
sequenceDiagram
  participant Adapter as Capture adapter
  participant Ingest as ingest :8082
  participant Raw as raw_turns
  participant Pool as worker.Pool

  Adapter->>Ingest: POST /v1/ingest
  alt Content-Length over MaxIngestBodyBytes
    Ingest-->>Adapter: 413 reject_oversize
  else envelope decoded
    Ingest->>Ingest: reduce raw-only if needed
    Ingest->>Raw: PutRawTurn before provider parse
    alt processTurn admits
      Ingest->>Pool: Admit
      Ingest-->>Adapter: 202 accepted
    else unknown provider or invalid reduction
      Ingest-->>Adapter: 422 (raw row already stored)
    else queue or storage
      Ingest-->>Adapter: 502
    end
  end
```

:::endpoint GET /ping Liveness probe
**Operation id:** `ingestPing`

Does not touch PostgreSQL. A 200 means the Fiber process is serving.

<ResponseExample>
```json
{ "status": "ok" }
```
</ResponseExample>
:::

:::endpoint POST /v1/ingest Ingest one captured turn
**Operation id:** `ingestTurn`

Appends one completed LLM turn to `raw_turns` (`source: wire`) and enqueues the reduced turn for derivation.

**Idempotency.** When `meta.request_id` is set, `PutRawTurn` dedupes on `(org, request_id)`. An empty `request_id` disables raw-layer dedup for that row.

**Persist-before-parse.** The verbatim envelope is written to the raw layer *before* provider request parsing. A turn that later returns `422` is still captured; a parser fix re-derives it instead of requiring a re-capture. A raw-layer persist failure on this path is logged and not returned as HTTP — the handler still runs `processTurn`, which surfaces a real storage outage as `502`.

**Reduction.** The adapter may send:

| Payload | What ingest stores |
| --- | --- |
| Reduced `response` only | Historical shape. Stored as-is. |
| `response` + `raw_response` | Both. Ingest does **not** re-reduce; the live adapter may have seen framing the stored bytes no longer show. |
| `raw_response` only (empty reduction) | Server-side reduce with the shared `pkg/capture` reducers so two capture paths produce identical rows. Reduce failure is **not** an ingest failure: the bytes still land. Re-derive does **not** re-reduce those bytes today. |

Supported `provider` values: `openai`, `anthropic`, `ollama`. Anything else is `422`.

<ParamField body="provider" type="string" required>
Provider type: `openai`, `anthropic`, or `ollama`.
</ParamField>
<ParamField body="agent_name" type="string">
Optional agent tag (same role as the `X-Tapes-Agent-Name` capture header).
</ParamField>
<ParamField body="request" type="object" required>
Verbatim provider request body. Stored as raw JSON; never re-marshaled through a parsed struct.
</ParamField>
<ParamField body="response" type="object">
Already-reduced `llm.ChatResponse`. Required for the derive enqueue unless a raw-only reduce succeeded first. Validation: `message.role` set, `message.content` non-empty, every block has `type`.
</ParamField>
<ParamField body="raw_response" type="string">
Upstream response bytes, JSON base64. Stored under `raw_response_encoding` without decompressing.
</ParamField>
<ParamField body="raw_response_encoding" type="string">
Content-Encoding of `raw_response` (`identity`, `gzip`, …). Empty means identity.
</ParamField>
<ParamField body="raw_response_withheld" type="boolean">
Producer captured verbatim bytes and omitted them (usually to stay under the body cap). Marks `raw_response_dropped` when no bytes arrived. Ignored if bytes are also present.
</ParamField>
<ParamField body="meta" type="object">
Capture metadata. `request_id` is the canonical attempt id (dedup key). `upstream_request_id` is provider-issued. `thread_id` is the harness sub-thread (`""` for main). `ts_request` / `captured_at` are RFC 3339 capture clocks used when reducing raw-only. Unknown keys survive in the stored JSON.
</ParamField>
<ParamField body="session" type="object">
Optional session envelope. Absent → `harness_id="unknown"` and a synthetic `harness_session_id` from the turn Merkle-root prefix (16 hex chars).
</ParamField>

<RequestExample>
```json
{
  "provider": "openai",
  "agent_name": "e2e-test",
  "request": {
    "model": "gpt-4",
    "messages": [{ "role": "user", "content": "What is content addressing?" }]
  },
  "response": {
    "model": "gpt-4",
    "message": {
      "role": "assistant",
      "content": [{ "type": "text", "text": "Content addressing stores data by hash, not location." }]
    },
    "done": true,
    "stop_reason": "stop",
    "usage": { "prompt_tokens": 12, "completion_tokens": 25, "total_tokens": 37 }
  },
  "meta": { "request_id": "req-001" }
}
```
</RequestExample>

<ResponseExample>
```json
{ "status": "accepted" }
```
</ResponseExample>

`202` means captured and queued, not that sessions/traces/spans exist yet.

| Status | When |
| --- | --- |
| `202` | Turn reached the raw layer and the worker pool admitted it. |
| `400` | JSON decode failed, or `session` failed `IngestEnvelope.Validate`. |
| `413` | Body over `MaxIngestBodyBytes` (never parsed). |
| `422` | Well-formed envelope that cannot be processed: unknown provider, unparseable `request`, invalid reduced `response`. Raw row already stored. |
| `502` | Worker queue full, retained-byte budget exceeded, or other downstream failure. |
:::

:::endpoint POST /v1/ingest/transcript Ingest one harness transcript
**Operation id:** `ingestTranscript`

Appends one transcript file — main session, one subagent, or a Codex `sub_agent_activity` anchor — to `raw_turns` (`source: transcript`). No node-path / derive work happens here. Requires a driver that implements `storage.RawTurnStore` (Postgres). The in-memory driver returns `501`.

**Idempotency.** Dedup key is a content hash of `records`:

```text
transcript:{harness_session_id}:{agent_id|main}:{sha256(records)[:8]}
```

Re-uploading unchanged bytes returns `202` with `deduped: true`. A grown file appends a new version. The deriver reads the latest version per `(session, agent, lifecycle kind)`, so an `interacted` row does not supersede a spawn anchor.

<ParamField body="session" type="object" required>
Must include `harness_session_id`. Codex spawn anchors key this to the **root** session, never the child thread.
</ParamField>
<ParamField body="agent_id" type="string">
Empty for the main transcript; subagent / child thread id otherwise.
</ParamField>
<ParamField body="agent_type" type="string">
Harness `meta.json` agent type.
</ParamField>
<ParamField body="description" type="string">
Harness `meta.json` description (often a path).
</ParamField>
<ParamField body="tool_use_id" type="string">
Task / `spawn_agent` call that forked this agent — the causal edge the deriver joins.
</ParamField>
<ParamField body="kind" type="string">
Empty / omitted = spawn evidence. `"interacted"` = Codex re-entry (`send_message` / `followup_task`); stored for later rendering and ignored by derivation.
</ParamField>
<ParamField body="records" type="array" required>
Transcript JSONL as a JSON array, verbatim. A non-array is `400`.
</ParamField>

<ResponseField name="status" type="string">
Always `accepted` on `202`.
</ResponseField>
<ResponseField name="deduped" type="boolean">
`true` when this exact content version was already stored. That is success — do not retry.
</ResponseField>
<ResponseField name="records" type="integer">
Count of array elements in `records`.
</ResponseField>
<ResponseField name="agent_id" type="string">
Echo of the payload; empty for the main file.
</ResponseField>

<ResponseExample>
```json
{ "status": "accepted", "deduped": false, "records": 1, "agent_id": "" }
```
</ResponseExample>

| Status | When |
| --- | --- |
| `202` | Stored, or already present (`deduped: true`). |
| `400` | Malformed body, invalid session, missing `harness_session_id`, or `records` not a JSON array. |
| `413` | Body over `MaxIngestBodyBytes`. |
| `422` | Content Postgres JSONB refuses (`storage.ErrInvalidContent`). Retrying the same bytes will not succeed. |
| `500` | Server-side marshal of meta / session JSON failed. |
| `501` | Driver does not host the raw-turn layer. |
| `502` | Other `PutRawTurn` failures (outage). The compiled contract currently lists `500` for persist failure; adapters should treat both `500` and `502` as storage faults. |
:::

## Session envelope

`session` is `sessions.IngestEnvelope`. This deployment is **single-tenant**: ingest clears `org_id` on every write (payload and `x-paper-auth-org-id` alike) so a client cannot store rows the nil-scoped read side will never surface.

| Field | Constraint |
| --- | --- |
| `org_id` | If non-empty, must be a UUID at the HTTP boundary — then ingest blanks it. |
| `auth_subject` | Overridden by `x-paper-auth-subject` when that header is present. |
| `harness_id` | Empty normalizes to `unknown`. |
| `harness_session_id` | Required on the transcript path. On the turn path, missing / `unknown` harness synthesizes an id from the Merkle-root prefix. |
| `parent_harness_session_id` | Omit if absent. An explicit empty string is `400`. Parent and child share a harness; ingest placeholder-inserts the parent if its first turn has not landed. |
| `harness_metadata` | Must be a JSON object (Postgres `\|\|` merge). Arrays/scalars are `400`. |

Trusted headers (same names `extproc/headers` reads):

| Header | Role |
| --- | --- |
| `x-paper-auth-org-id` | Ignored for storage in single-tenant mode. |
| `x-paper-auth-subject` | Overrides `session.auth_subject` when set. The gateway must strip inbound client values. |

## Body cap and 413 `reject_oversize`

Two different ceilings apply. Confusing them produces the wrong operator action.

```text
POST body          MaxIngestBodyBytes   (~46.67 MiB)  →  413, turn never stored
raw_response bytes MaxRawResponseBytes  (8 MiB)       →  drop + mark, turn still accepted
```

`MaxIngestBodyBytes` is derived, not a free-standing magic number:

| Constant | Value | Role |
| --- | --- | --- |
| `MaxDecodedRequestBytes` | `32 MiB` | Anthropic Messages decoded-request ceiling. |
| `MaxRawResponseBytes` | `8 MiB` | Verbatim response stored on one row. |
| reserve | `4 MiB` | Reduced `response`, `meta`, JSON scaffolding. |
| `MaxIngestBodyBytes` | `32 MiB + 8 MiB × 4/3 + 4 MiB` = **48 933 546 bytes** | Fiber `BodyLimit`. |

The `× 4/3` term is base64 expansion of `raw_response` on the wire. Fiber's 4 MiB default is **not** the real limit — if it were, a legal raw-bearing envelope would 413 before the 8 MiB drop path could run.

Over-limit POSTs to `/v1/ingest` or `/v1/ingest/transcript`:

- HTTP `413`
- JSON body `{"error":"request body exceeds the ingest size limit"}` — same `llm.ErrorResponse` envelope as every other rejection
- Fiber rejects on declared `Content-Length` and does not parse the body
- One Prometheus increment: `tapes_ingest_writes_total{provider="unknown",status="reject_oversize"}`
- One warn log with `content_length`, `limit`, and `path`
- The accepted-size histogram is **not** updated (`bodyBytes=0`)

A `413` on any other path keeps Fiber's default (plain-text) handler and does **not** increment `reject_oversize`.

An 8 MiB+ `raw_response` that still fits the POST is stored without the bytes, with `raw_response_dropped=true`. Reduction already ran, so the row keeps request, reduced response, and session attribution. `raw_response_withheld: true` with no bytes sets the same marker (producer dropped to fit the transport).

## Error envelope

Every documented failure uses:

```json
{ "error": "<message>" }
```

That is `llm.ErrorResponse`. Capture adapters can parse all failures uniformly — including `413`.

Turn-path sentinels map to status:

| Sentinel | HTTP | Typical cause |
| --- | --- | --- |
| `ErrEnvelope` | `400` | Decode / session validation |
| `ErrUnprocessable` | `422` | Unknown provider, parse, invalid reduction |
| `ErrDownstream` | `502` | Worker queue full, storage |
| `ErrWorkerByteBudget` | `502` | Worker retained-byte budget (wraps `ErrDownstream`) |

`GET /openapi` compile failure is a process defect and uses `{ "error": "openapi_compile_failed", "message": "..." }` — that path is not part of the adapter write contract.

## Metrics

Scraped at `GET /metrics` from this process's private registry.

| Metric | Labels | Notes |
| --- | --- | --- |
| `tapes_ingest_writes_total` | `provider`, `status` | Empty provider becomes `unknown`. Transcript writes use `provider="transcript"`. |
| `tapes_ingest_dag_write_seconds` | `provider` | Enqueue latency. |
| `tapes_ingest_worker_queue_depth` | — | Ingest-side snapshot only. |
| `tapes_ingest_body_bytes` | `provider` | Accepted envelopes only. |
| `tapes_ingest_rawonly_stamp_total` | `provider`, `field`, `source` | Whether raw-only reduce restored `duration` / `created_at` from `elapsed_seconds`, `captured_at`, `ts_request`, or `fallback`. |

Write `status` values: `accepted`, `reject_envelope`, `reject_parse`, `unknown_provider`, `queue_full`, `queue_byte_budget`, `downstream_error`, `internal_error`, `reject_oversize`.

## Operator notes

<Steps>
<Step title="Confirm ingest, not the read API">
`GET http://127.0.0.1:8082/ping` → `{"status":"ok"}`. `GET /openapi` title is `Tapes Ingest API`. Pointing `tapesctl start` at `:8081` is the wrong contract.
</Step>
<Step title="Require Postgres for transcripts">
`POST /v1/ingest/transcript` without a `RawTurnStore` is `501`. Wire ingest still needs Postgres in production so persist-before-parse has a home.
</Step>
<Step title="Treat 202 as queued">
Sessions, traces, and spans appear after the derive worker projects `raw_turns`. Inspect them on `:8081`.
</Step>
<Step title="Do not retry a 413 by resending the same body">
Split or withhold `raw_response` (`raw_response_withheld: true`) so the reduced turn still lands. Retrying an unchanged oversize POST will 413 forever.
</Step>
<Step title="Do not retry transcript 202 with deduped=true">
That exact content version is already stored.
</Step>
</Steps>

<AccordionGroup>
<Accordion title="413 vs missing raw bytes">
`413` means the HTTP POST never entered the handler. A stored row with `raw_response` empty and `raw_response_dropped` true means the turn arrived and verbatim bytes were withheld or capped at 8 MiB. Those are opposite fidelity facts.
</Accordion>
<Accordion title="422 after a successful capture">
The raw envelope is already on disk. Fix the parser / reducer and re-derive. Do not re-POST unless `request_id` is empty (no dedup) or you intend a new attempt id.
</Accordion>
<Accordion title="502 queue_byte_budget vs queue_full">
Both are `502`. The metric label tells which worker ceiling saturated: retained bytes vs slot count.
</Accordion>
<Accordion title="Changing the contract">
Move a route, field, or status code only with a seal bump in `ingest/CONTRACT`. Out-of-repo adapters (`tapesctl`, `tapes-extproc`, `paperd`) are not built by this CI and will discover an unannounced change in production.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup>
<Card title="Read API vs ingest" href="/read-vs-ingest">
Two sealed contracts, two ports, and why `:8082` stays private.
</Card>
<Card title="Read API" href="/read-api">
The public `:8081` contract this write surface does not implement.
</Card>
<Card title="Capture and derive" href="/capture-and-derive">
What happens to a raw turn after `202`.
</Card>
<Card title="Gateway capture" href="/gateway-capture">
`tapes-extproc` POSTs completed turns at this envelope.
</Card>
<Card title="Prove the capture ratchet" href="/capture-ratchet">
Raw-only reduce and offline equivalence against stored reductions.
</Card>
<Card title="Split the stack" href="/split-the-stack">
Run ingest as its own process and failure domain.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Wrong-port capture and ingest `413`.
</Card>
</CardGroup>

---

## 18. MCP

> Streamable HTTP MCP at /v1/mcp: cassette tools marked x-tapes-mcp, the legacy search tool, and stateless transport limits.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/18-mcp.md
- Generated: 2026-08-18T20:18:27.911Z

### Source Files

- `api/mcp/mcp.go`
- `api/mcp/search.go`
- `api/mcp/cassette.go`
- `api/cassetterunner/mcp.go`
- `api/openapi_routes.go`
- `docs/mcp.md`

---
title: "MCP"
description: "Streamable HTTP MCP at /v1/mcp: cassette tools marked x-tapes-mcp, the legacy search tool, and stateless transport limits."
---

The read API (`tapes serve` / `tapes serve api`, default `:8081`) mounts a **stateless streamable HTTP** Model Context Protocol server at `/v1/mcp`. The implementation name is `tapes` (binary `utils.Version`). Each request snapshots the live cassette registry; when both a span searcher and an embedder are configured, the server also registers the legacy core tool `search`.

Configure an MCP client as a **streamable HTTP** server pointed at:

```text
http://localhost:8081/v1/mcp
```

Do not point MCP at ingest (`:8082`). Cassette tools call admitted cassette origins the same way the cassette HTTP proxy does; they are not ingest writes.

## Endpoint

One Fiber `All` route wraps the MCP SDK's streamable HTTP handler. Only these verbs are published in `GET /openapi` (tag `mcp`):

| Method | Operation ID | Role |
| --- | --- | --- |
| `POST` | `invokeMcp` | JSON-RPC 2.0 (`initialize`, `tools/list`, `tools/call`, …) |
| `GET` | `openMcpStream` | Server-sent event stream (`text/event-stream`) |
| `DELETE` | `closeMcpSession` | Session-termination semantics |

The published request/response types are deliberately loose JSON-RPC 2.0 envelopes (`params` / `result` are method-dependent). The SDK implements the method set; this server does not re-declare MCP method schemas as first-class Go types.

:::endpoint POST /v1/mcp Invoke streamable MCP
JSON-RPC 2.0 body. Typical methods: `initialize`, `tools/list`, `tools/call`.

**Responses**

- `200` — JSON-RPC result or tool result
- `400` — invalid JSON-RPC
- `500` — server-side MCP error
:::

:::endpoint GET /v1/mcp Open MCP event stream
SSE stream for streamed MCP messages on the same path.
:::

:::endpoint DELETE /v1/mcp Close MCP session
Requests termination when a client uses session-oriented transport semantics.
:::

<RequestExample>
```json
{
  "jsonrpc": "2.0",
  "id": "1",
  "method": "tools/call",
  "params": {
    "name": "search",
    "arguments": {
      "query": "how was logging configured?",
      "top_k": 3
    }
  }
}
```
</RequestExample>

## Transport limits

The handler is created with `StreamableHTTPOptions{Stateless: true}`. Capabilities advertise `tools.listChanged: false`.

| Constraint | Behavior |
| --- | --- |
| Stateless HTTP | No durable MCP session on the server. `tools/list` is a per-request snapshot of `Registry.Instances()`. |
| No list-changed notifications | Cassette admit / refresh / remove does not push `notifications/tools/list_changed`. Reconnect or call `tools/list` again. |
| Cassette tool timeout | `30s` per upstream POST. |
| Cassette success body | Hard cap `8 MiB`. Oversize is a tool error. |
| Cassette error body | Non-2xx payload truncated at `4 KiB` before it is placed in the tool error text. |
| Cassette HTTP client | Redirects refused (`CheckRedirect` → last response). Shared with the cassette REST proxy. |
| Tool count | At most `128` advertised tools **per cassette**. Over that refuses the refreshed document. |

<Warning>
Because the transport is stateless, a client that stays connected while the cassette fleet changes will not be notified. Call `tools/list` again after you admit, refresh, or remove a cassette.
</Warning>

```mermaid
sequenceDiagram
  participant Client as MCP client
  participant API as Read API :8081 /v1/mcp
  participant Reg as Cassette registry
  participant Emb as Embedder + span store
  participant Cass as Cassette origin

  Client->>API: POST initialize / tools/list
  API->>Reg: Instances() snapshot
  API-->>Client: tools (cassette.* plus optional search)

  alt tools/call search
    Client->>API: arguments query, top_k
    API->>Emb: Embed(query) then Search(nil-org, vector, top_k)
    Emb-->>API: span hits
    API-->>Client: structured SearchOutput + JSON text
  else tools/call cassette.tool
    Client->>API: JSON object arguments
    API->>Cass: POST local path, JSON body, X-Tapes-Cassette
    Cass-->>API: 2xx JSON object
    API-->>Client: structured content (tool error on non-2xx)
  end
```

## Cassette tools

A cassette advertises tools by putting `x-tapes-mcp` on an **operation** (not on the document, not on a path item, and not inside `x-tapes-cassette`). Admission extracts tools from the **republished** OpenAPI document (paths already rewritten under `/v1/cassettes/{name}`). A bad `x-tapes-mcp` declaration refuses the whole refreshed document. A later invalid refresh keeps the last admitted document **and** its tools.

Published tool name:

```text
{cassetteName}.{x-tapes-mcp.name}
```

Example: cassette `summary` + `"name": "summarize_session"` → `summary.summarize_session`. The bundled example cassette publishes `mcp-tool.ping`.

<ParamField body="x-tapes-mcp.name" type="string" required>
Local tool name. ASCII letters, digits, `.`, `-`, `_` only. Combined with the cassette name the full MCP name must be ≤ 128 bytes.
</ParamField>

<ParamField body="x-tapes-mcp.annotations" type="object">
MCP hints only: `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. They are not authorization. Unknown extension fields are ignored.
</ParamField>

Title is the operation `summary`. Description is operation `description`, falling back to `summary`.

### Admission contract

`x-tapes-mcp` is accepted only when all of the following hold:

| Rule | Requirement |
| --- | --- |
| Document | OpenAPI **3.1** (JSON Schema) |
| Method | `POST` only |
| Parameters | No path, query, header, or cookie parameters. All arguments in the JSON body. |
| Request body | Inline (no request-body `$ref`), **required**, `application/json`, schema resolves to an **object** |
| Schema refs | Local `#/components/schemas/...` are bundled into a standalone MCP input schema. Remote refs are refused. Root `type` is forced to `"object"`. |
| Responses | At least one inline `2xx` / `2XX` `application/json` **object** schema |
| Placement | Extension on the operation. Document- or path-level `x-tapes-mcp` is an admission error. |
| Uniqueness | Duplicate `x-tapes-mcp.name` values in one cassette are refused |

Malformed advertised tools fail cassette refresh. Changing a tool changes the republished OpenAPI ETag; it does not change the cassette manifest digest. Admitting a cassette also trusts its operation and schema prose — MCP clients may place that text in an agent context.

```json
{
  "post": {
    "operationId": "summarizeSession",
    "summary": "Summarize a session",
    "x-tapes-mcp": {
      "name": "summarize_session",
      "annotations": {
        "readOnlyHint": true,
        "idempotentHint": true,
        "openWorldHint": false
      }
    },
    "requestBody": {
      "required": true,
      "content": {
        "application/json": {
          "schema": {
            "type": "object",
            "properties": { "session_id": { "type": "string" } },
            "required": ["session_id"]
          }
        }
      }
    },
    "responses": {
      "200": {
        "description": "Summary",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": { "summary": { "type": "string" } }
            }
          }
        }
      }
    }
  }
}
```

Cassettes that need other HTTP semantics should expose a JSON-body POST facade rather than expecting Tapes to act as a general OpenAPI client.

### Call path

`tools/call` on a cassette tool:

1. Validates arguments against the admitted input schema (invalid args never hit the cassette).
2. Marshals arguments as the JSON POST body (`{}` when the client omits arguments and the schema allows it).
3. Rewrites the public path (`/v1/cassettes/{name}/…`) to the cassette local prefix (for example `/api/{name}/…`).
4. Copies caller identity and tracing headers from the inbound MCP HTTP request. Drops hop-by-hop / representation headers (`Accept-Encoding`, `If-None-Match`, spoofed `X-Forwarded-*`, and the rest of the cassette proxy exclusion list).
5. Sets `Accept` and `Content-Type` to `application/json`, `X-Tapes-Cassette: {name}`, and fresh forwarded headers.
6. POSTs to the admitted cassette origin (scheme/host/port of the configured OpenAPI URL). Redirects are not followed.

A successful 2xx body must be **exactly one JSON object** (not null, not an array, no trailing JSON). That object is returned as MCP structured content. Non-2xx status, transport failure, oversize body, or a non-object body is a tool error.

<CodeGroup>
```sh title="Admit the example cassette, then attach MCP"
make -C pkg/cassette/examples/mcp-tool run
tapes serve api --cassettes=http://127.0.0.1:9999/openapi
```

```sh title="Same operation over HTTP (not MCP)"
curl -X POST http://127.0.0.1:9999/api/mcp-tool/ping \
  -H 'Content-Type: application/json' \
  -d '{"ping":"ping"}'
# {"pong":"pong"}
```
</CodeGroup>

Standalone API refresh interval defaults to `--cassette-refresh=30s`. `tapes serve` also takes `--cassettes` / `cassettes` in config. Tapes does not start cassette processes.

## Legacy core search tool

While span search is being extracted to a cassette, the core tool is registered only when **both** `SpanSearcher` and `Embedder` are set (and a logger is required in that case). Supplying only one of the two is a server construction error. Omitting both is valid: `/v1/mcp` still serves cassette tools.

`tapes serve` always constructs the embedder and span store. `tapes serve api` attaches them only when a vector-store target is set (it defaults to `storage.postgres_dsn` when that DSN is present). `--embed-spans=false` stops the in-process embed worker; it does not remove the `search` tool if the embedder and store were still created.

| Field | Value |
| --- | --- |
| Name | `search` |
| Required | `query` string |
| Optional | `top_k` integer; `<= 0` or omitted → `5` |
| Tenant | Always `00000000-0000-0000-0000-000000000000` (same nil-org bucket as header-less `GET /v1/search/spans`) |
| Index | Main-conversation LLM spans, delta-only content |

The tool embeds `query` with the configured embedding provider, then runs the same `SpanSearcher.Search` path as `GET /v1/search/spans`.

<ParamField body="query" type="string" required>
Search text. Embedded, then compared to the span projection.
</ParamField>

<ParamField body="top_k" type="integer">
Result cap. Default `5`.
</ParamField>

<ResponseField name="query" type="string">
Echo of the input query.
</ResponseField>

<ResponseField name="count" type="integer">
Number of hits in this response.
</ResponseField>

<ResponseField name="results" type="SearchResult[]">
Each hit: `session_id`, `trace_id`, `span_id`, `score`, `user_prompt`, `snippet`, `model`, `started_at`.
</ResponseField>

Structured content is also serialized as a JSON `TextContent` block.

<ResponseExample>
```json
{
  "query": "how was logging configured?",
  "count": 1,
  "results": [
    {
      "session_id": "sess-1",
      "trace_id": "trace-1",
      "span_id": "span-1",
      "score": 0.91,
      "user_prompt": "how do I do X?",
      "snippet": "you do X like this",
      "model": "claude-opus-4",
      "started_at": "2026-06-15T12:00:00Z"
    }
  ]
}
```
</ResponseExample>

Search failures (embedder error, store error, `spanembed.ErrNotInitialized`) return an MCP **tool error** (`IsError: true`) rather than a JSON-RPC transport failure. An uninitialized span-embedding projection means the embed worker has not created/populated the table yet.

## Enable it locally

<Steps>
<Step title="Start storage, embeddings, and the API">
```bash
tapes local up
tapes serve
```

`tapes local up` brings up PostgreSQL/pgvector and the local embedding model. `tapes serve` listens on `:8081` for the read API (including `/v1/mcp`) and embeds eligible spans in the background by default.
</Step>
<Step title="Point a client at streamable HTTP">
Use `http://localhost:8081/v1/mcp`. Seed or capture data before expecting `search` hits:

```bash
tapesctl seed --tapes-url http://localhost:8081
```
</Step>
<Step title="Optional: attach cassette tools">
Start the cassette yourself, then pass its OpenAPI URL:

```bash
tapes serve --cassettes=http://127.0.0.1:9999/openapi
```

Or set `cassettes = ["http://127.0.0.1:9999/openapi"]` in `.tapes/config.toml`.
</Step>
</Steps>

<Info>
If search dependencies are not configured, `search` is omitted from `tools/list`; cassette tools stay available. If the store exists but the span projection is not initialized, `search` is listed and fails until the embed worker writes embeddings.
</Info>

## Scope and authorization

Header-less core MCP search uses the same nil-org tenant UUID as header-less HTTP search. Cassette tools expose whatever the admitted POST implements. MCP annotations are descriptive hints. Caller `Authorization` and other non-excluded inbound headers are forwarded to the cassette; they do not replace gateway or cassette authorization.

## Troubleshooting

| Symptom | What to check |
| --- | --- |
| `search` missing from `tools/list` | Standalone API has no vector-store / Postgres DSN, or embedder + span store were not both constructed. Cassette tools can still appear. |
| `search` tool error contains `not initialized` | Embed worker has not prepared the span-embedding projection. Confirm `tapes serve` embed-spans (or `tapes serve embed-worker`) and matching `embedding.model` / `embedding.dimensions`. |
| Cassette tool missing after edit | Invalid `x-tapes-mcp` refuses the refresh; last good tools remain. Check `GET /v1/cassettes` rejections. Re-run `tools/list`. |
| Cassette tool never invoked | Schema validation failed (required body fields). The cassette sees zero requests. |
| Cassette tool error with HTTP status | Upstream returned non-2xx, a non-object JSON body, trailing JSON, null, or a body over 8 MiB. |
| Stale tool list | Expected: `listChanged` is false. Re-list or reconnect. |
| Wrong port | MCP is on the **read** API (`:8081`), not capture/ingest (`:8082`) or the LLM proxy (`:8080`). |

## Related pages

<CardGroup>
<Card title="Cassettes" href="/cassettes">
`cassette/v1alpha1` admission, `x-tapes-mcp`, and the `/v1/cassettes/{name}` proxy.
</Card>
<Card title="Search spans" href="/search-spans">
`GET /v1/search/spans`, `tapesctl search`, and the same span projection the MCP `search` tool uses.
</Card>
<Card title="Read API" href="/read-api">
Compiled `GET /openapi` on `:8081`, including the `mcp` tag.
</Card>
<Card title="Configure embeddings" href="/configure-embeddings">
`embedding.provider`, model, dimensions, `tapes auth`, and the embed worker.
</Card>
<Card title="Run a cassette locally" href="/run-a-cassette">
Admit the bundled hello-world cassette and call the republished surface.
</Card>
</CardGroup>

---

## 19. Split the stack

> Run proxy, api, ingest, derive-worker, and embed-worker as separate processes, including flags, DSNs, and independent failure domains.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/19-split-the-stack.md
- Generated: 2026-08-18T20:18:55.329Z

### Source Files

- `cmd/tapes/serve/stack.go`
- `cmd/tapes/serve/serve.go`
- `cmd/tapes/serve/api/api.go`
- `cmd/tapes/serve/proxy/proxy.go`
- `cmd/tapes/serve/ingest/ingest.go`
- `cmd/tapes/serve/deriveworker/deriveworker.go`
- `cmd/tapes/serve/embedworker/embedworker.go`

---
title: "Split the stack"
description: "Run proxy, api, ingest, derive-worker, and embed-worker as separate processes, including flags, DSNs, and independent failure domains."
---

`tapes serve` starts the capture proxy, read API, private ingest API, in-process derive worker, and (by default) in-process embed worker in one process. The same binary also exposes `tapes serve proxy`, `tapes serve api`, `tapes serve ingest`, `tapes serve derive-worker`, and `tapes serve embed-worker` so each surface can run as its own process, with its own listen address, memory budget, and crash domain.

`tapes` is the server and owns PostgreSQL. Capture clients (`tapesctl start`, `tapesctl capture`) and the Envoy adapter (`tapes-extproc`) are not serve subcommands.

<Warning>
A fatal error from any goroutine in `tapes serve` exits the whole process. Split the workers when derivation must keep projecting after a search-backend outage, or when a large-session derive must not share the API pod's memory.
</Warning>

## Process map

```mermaid
flowchart TB
  subgraph clients["Clients"]
    agent["Agent / provider SDK"]
    ctlRead["tapesctl read commands"]
    ctlCap["tapesctl start / capture"]
    gw["tapes-extproc or gateway"]
  end

  subgraph serveProcs["tapes serve processes"]
    proxy["proxy :8080"]
    ingest["ingest :8082"]
    api["api :8081"]
    derive["derive-worker"]
    embed["embed-worker"]
  end

  pg[("PostgreSQL\nstorage.postgres_dsn\nraw_turns + derived + pgvector")]
  embProv["Embedding provider\nembedding.provider / .target"]

  agent -->|"provider paths"| proxy
  ctlCap -->|"POST /v1/ingest*"| ingest
  gw -->|"POST /v1/ingest"| ingest
  ctlRead -->|"GET /v1/*"| api

  proxy --> pg
  ingest --> pg
  derive --> pg
  embed --> pg
  api --> pg
  embed --> embProv
  api -->|"search query embed"| embProv
```

| Process | Command | Default listen | Writes | Reads |
| --- | --- | --- | --- | --- |
| Combined stack | `tapes serve` | `:8080`, `:8081`, `:8082` | proxy + ingest + in-process workers | API |
| Proxy | `tapes serve proxy` | `proxy.listen` (`:8080`) | `raw_turns` via the proxy | — |
| Ingest | `tapes serve ingest` | `ingest.listen` (`:8082`) | `raw_turns` via HTTP | — |
| API | `tapes serve api` | `api.listen` (`:8081`) | admin derive / skills / cassettes | derived model + search |
| Derive worker | `tapes serve derive-worker` | none (optional `--metrics-listen`) | sessions / traces / spans | dirty queue + `raw_turns` |
| Embed worker | `tapes serve embed-worker` | none (optional `--metrics-listen`) | span vectors | derived LLM spans |

Cassette processes are operator-owned and are not started by `tapes serve`. Admit them with `--cassettes` on `tapes serve` or `tapes serve api`.

## Combined vs split

| Behavior | `tapes serve` | Split processes |
| --- | --- | --- |
| Derive debounce | `2s` (local capture loop) | `20s` (`derive_worker.debounce`) |
| Embed interval | `10s` when `--embed-spans` is on | `1m` (`embed_worker.interval`) |
| Embed on by default | yes (`--embed-spans`, disable with `=false`) | only if you run `embed-worker` |
| Embed setup failure | logs a warning and leaves search off; stack stays up | `embed-worker` exits; API stays up |
| Creating the embedder | fails the whole stack | fails `api` / `embed-worker` only |
| First service crash | process exits | other processes keep running |
| Worker HTTP probes | not served | `--metrics-listen` serves `/metrics`, `/healthz`, `/readyz`, `/ping` |

Standalone `derive-worker` is the production form. The package comment forbids hosting that loop inside the API container: a full derive once OOM-killed a 256Mi API pod. Extra derive or embed replicas are safe — derive takes a per-session Postgres advisory lock; embed is keyed by span identity and a content hash.

## Shared DSN

Every process that persists opens `storage.postgres_dsn` (`--postgres` / `TAPES_STORAGE_POSTGRES_DSN`). `vector_store.target` (`--vector-store-target`) defaults to that same DSN. `derive-worker` and `embed-worker` refuse to start when the DSN is empty.

<ParamField body="storage.postgres_dsn" type="string" required>
Capture and derived PostgreSQL URL. Example: `postgres://tapes:tapes@localhost:5432/tapes?sslmode=disable`.
</ParamField>

<ParamField body="vector_store.target" type="string">
pgvector connection. Unset copies `storage.postgres_dsn`.
</ParamField>

<ParamField body="proxy.project" type="string">
Project tag on captured sessions. Unset uses the Git repo name.
</ParamField>

Workers use a 4-connection pool and a 10s connect timeout. Unreachable Postgres fails startup unless `--wait-for-db` is set; with that flag the worker retries with exponential backoff (cap 30s) until the DSN answers or the process is cancelled.

All five processes must see the same database. Split listen addresses, not the store.

## Flags

Precedence is flag, then `TAPES_…`, then `.tapes/config.toml`, then built-in defaults. Dots become underscores: `storage.postgres_dsn` → `TAPES_STORAGE_POSTGRES_DSN`.

Parent `tapes serve` uses `--proxy-listen` / `-p`, `--api-listen` / `-a`, `--ingest-listen` / `-i`. Each standalone HTTP command uses `--listen` / `-l` bound to the same viper key.

### Parent `tapes serve`

| Flag | Config key | Default / notes |
| --- | --- | --- |
| `--proxy-listen` / `-p` | `proxy.listen` | `:8080` |
| `--api-listen` / `-a` | `api.listen` | `:8081` |
| `--ingest-listen` / `-i` | `ingest.listen` | `:8082` |
| `--api-web-ui` | `api.web_ui` | `false` |
| `--upstream` / `-u` | `proxy.upstream` | `http://localhost:11434` |
| `--provider` | `proxy.provider` | `ollama` (`anthropic`, `openai`, `ollama`) |
| `--postgres` | `storage.postgres_dsn` | unset |
| `--project` | `proxy.project` | Git repo name |
| `--vector-store-target` | `vector_store.target` | primary DSN |
| `--embedding-provider` | `embedding.provider` | `ollama` |
| `--embedding-target` | `embedding.target` | `http://localhost:11434` |
| `--embedding-model` | `embedding.model` | `embeddinggemma` |
| `--embedding-dimensions` | `embedding.dimensions` | `768` |
| `--cassettes` | `cassettes` | OpenAPI URLs, CSV or repeated |
| `--cassette-refresh` | (flag only) | `30s` |
| `--embed-spans` | (flag only) | `true` |

### Standalone listen and write path

`tapes serve proxy` and `tapes serve ingest` still accept `--embedding-*` and `--vector-store-target` so old deployments boot. Those flags do nothing: the embed worker is the only writer of vectors.

<Note>
You do not need both write paths. Use `proxy` when clients talk a provider protocol on `:8080`. Use `ingest` when `tapesctl` or a gateway POSTs completed turns to `:8082`. Running both against the same DSN is supported and is what `tapes serve` does.
</Note>

`tapes serve api` also takes `--web-ui` (not `--api-web-ui`), `--skill-model` / `skill.model`, `--cassettes`, and `--cassette-refresh`. The API builds a search embedder when a vector-store target is set (including the default copy of the Postgres DSN). It does not create the embedding schema; until a writer has run `EnsureSchema`, `GET /v1/search/spans` answers `503`.

### `tapes serve derive-worker`

<ParamField body="--postgres" type="string" required>
`storage.postgres_dsn`
</ParamField>

<ParamField body="--project" type="string">
`proxy.project`
</ParamField>

<ParamField body="--poll-interval" type="duration">
`derive_worker.poll_interval`. Default `5s`.
</ParamField>

<ParamField body="--debounce" type="duration">
`derive_worker.debounce`. Default `20s`. A session's dirty mark must stay quiet this long before derive, unless `--max-derive-lag` fires first.
</ParamField>

<ParamField body="--max-derive-lag" type="duration">
`derive_worker.max_derive_lag`. Default `45s`. Derives a still-streaming session so live views do not wait for a quiet gap.
</ParamField>

<ParamField body="--sweep-interval" type="duration">
`derive_worker.sweep_interval`. Default `1h`, plus one sweep at startup.
</ParamField>

<ParamField body="--sweep-window" type="duration">
`derive_worker.sweep_window`. Default `24h`. Negative sweeps all history (full re-derive after a deriver change).
</ParamField>

<ParamField body="--metrics-listen" type="string">
`derive_worker.metrics_listen`. Empty disables the listener.
</ParamField>

<ParamField body="--wait-for-db" type="boolean">
`derive_worker.wait_for_db`. Default fail-fast.
</ParamField>

The worker derives one session at a time. Poll failures back off exponentially, capped at 30s. SIGINT/SIGTERM drains the in-flight derive for up to 30s; a second signal kills immediately. If `GOMEMLIMIT` is unset, the process applies a cgroup-derived soft heap limit so a large-session re-parse does not OOM the container.

`POST /v1/admin/derive/run` on the API remains an on-demand escape hatch. It is not a substitute for the worker loop.

### `tapes serve embed-worker`

<ParamField body="--interval" type="duration">
`embed_worker.interval`. Default `1m`. One pass also runs at startup.
</ParamField>

<ParamField body="--batch-size" type="int">
`embed_worker.batch_size`. Default `100` when `0`.
</ParamField>

<ParamField body="--max-text-bytes" type="int">
`embed_worker.max_text_bytes`. Default `1 MiB` when `0`; negative disables. Oversized spans are recorded as `too_large`, not chunked.
</ParamField>

<ParamField body="--org" type="string">
`embed_worker.org`. Optional org UUID filter; default all orgs.
</ParamField>

<ParamField body="--metrics-listen" type="string">
`embed_worker.metrics_listen`
</ParamField>

<ParamField body="--wait-for-db" type="boolean">
`embed_worker.wait_for_db`
</ParamField>

`--embedding-provider`, `--embedding-target`, `--embedding-model`, and `--embedding-dimensions` must be a matching pair. `EnsureSchema` runs at startup and fails the process if an existing vector table disagrees. API keys come from `tapes auth` / `credentials.toml` via `APIKeyForProvider`, not from `config.toml`.

A per-span provider error is counted and retried on the next pass. Infrastructure failures back off up to 5m so a down backend is not hammered every interval. Drain and second-signal behavior match the derive worker.

## Run a split stack

<Steps>
<Step title="Share one DSN">
Point every process at the same store. `tapes local up` writes `storage.postgres_dsn` into the active `.tapes/config.toml`; in an orchestrator, inject `TAPES_STORAGE_POSTGRES_DSN` (or `--postgres`) on each container.
</Step>

<Step title="Start the write path you need">

<CodeGroup>
```bash title="Gateway / tapesctl capture"
tapes serve ingest \
  --listen :8082 \
  --postgres "$TAPES_STORAGE_POSTGRES_DSN"
```

```bash title="Fixed-port provider proxy"
tapes serve proxy \
  --listen :8080 \
  --provider anthropic \
  --upstream https://api.anthropic.com \
  --postgres "$TAPES_STORAGE_POSTGRES_DSN"
```
</CodeGroup>

Treat ingest as a private in-cluster write surface. Do not expose `:8082` as a public app port.
</Step>

<Step title="Start derivation and embedding as separate workloads">

```bash
tapes serve derive-worker \
  --postgres "$TAPES_STORAGE_POSTGRES_DSN" \
  --metrics-listen :9091 \
  --wait-for-db

tapes serve embed-worker \
  --postgres "$TAPES_STORAGE_POSTGRES_DSN" \
  --metrics-listen :9092 \
  --wait-for-db
```

Scale by adding replicas. Do not put either loop back inside the API process.
</Step>

<Step title="Start the read API">

```bash
tapes serve api \
  --listen :8081 \
  --postgres "$TAPES_STORAGE_POSTGRES_DSN" \
  --cassettes http://hello-world:9999/openapi
```

Point `tapesctl` read commands at `:8081` and capture commands at `:8082`.
</Step>

<Step title="Verify each failure domain">

```bash
curl -sS http://127.0.0.1:8081/ping
curl -sS http://127.0.0.1:8082/ping
curl -sS http://127.0.0.1:9091/healthz   # derive liveness; never depends on Postgres
curl -sS http://127.0.0.1:9091/readyz    # 503 until the dirty queue is pollable
curl -sS http://127.0.0.1:9092/readyz    # 503 until embed-worker can ping Postgres
tapes status --api-target http://localhost:8081
```

After a capture, wait at least the derive debounce (`20s` standalone, `2s` in `tapes serve`) before expecting sessions on `:8081`. Search stays empty until embed-worker has completed a pass.
</Step>
</Steps>

Root `docker-compose.yaml` runs the combined `serve` and publishes `:8080` and `:8081` only. The hello-world cassette compose runs `serve api` alone; add ingest and derive-worker as extra containers on the same network and DSN when you also want capture.

<RequestExample>
```bash title="Minimal three-process read path"
export TAPES_STORAGE_POSTGRES_DSN='postgres://tapes:tapes@localhost:5432/tapes?sslmode=disable'

tapes serve ingest --listen :8082 &
tapes serve derive-worker --wait-for-db &
tapes serve api --listen :8081 &

tapesctl config set tapes-url http://localhost:8081
tapesctl start claude --tapes-url http://localhost:8082
```
</RequestExample>

## Independent failure domains

| If this process dies | What continues | What stops |
| --- | --- | --- |
| `proxy` | ingest, derive, embed, API | new `:8080` captures |
| `ingest` | proxy, derive, embed, API | `tapesctl` / gateway POSTs to `:8082` |
| `api` | both write paths and both workers | reads, search, MCP, cassette proxy, admin derive |
| `derive-worker` | append-only `raw_turns`, embed of already-derived spans, API | new sessions / traces / spans go stale until the worker (or `POST /v1/admin/derive/run`) catches up |
| `embed-worker` | capture and derivation | search vectors go stale; configured-but-uninitialized search stays `503` |
| embedding backend | derive, ingest, proxy | embed-worker retries with backoff; query-time search embed on the API may fail |

`/healthz` on a worker is process liveness and must not depend on Postgres — a store outage must not get the pod killed. `/readyz` is the store (and, for derive, a pollable dirty queue). The metrics listener starts before the database connect so `/healthz` answers while `--wait-for-db` retries.

Embedding is never a step of the derive loop. A slow or down Ollama/OpenAI backend cannot stall projection of `raw_turns` into sessions, traces, and spans.

## What is not a serve process

- **`tapesctl`** — client. Capture talks to ingest; list/search/export talk to the API.
- **`tapes-extproc`** — separate image (`Dockerfile.extproc`). It POSTs completed turns to ingest and must not depend on the API, the database, or either worker being healthy.
- **Cassette binaries** — operator lifecycle. `tapes serve api --cassettes` only fetches their OpenAPI documents.

## Troubleshooting

| Symptom | Check |
| --- | --- |
| Combined stack exits when one listener dies | Expected. Split the failing service. |
| Sessions missing after capture | Derive worker down, or still inside debounce / max-lag. Confirm dirty-queue work with `--metrics-listen` or `POST /v1/admin/derive/run`. |
| Search `503` | Embed worker never ran `EnsureSchema`, or model/dimensions disagree with the vector table. |
| Search empty with `200` | Embed worker not running, or backend down. Derivation can still be current. |
| Worker exits immediately | Empty `--postgres`, or Postgres unreachable without `--wait-for-db`. |
| API boot fails on embeddings | Embedder constructor error. Fix `embedding.*` / `tapes auth`, or stop requiring search on that process. |
| Capture on the wrong port | `:8080` is the provider proxy; `:8082` is ingest. Mixing them drops the session. |
| Deprecated embedding flags on proxy/ingest | Logged as retired. Configure `embed-worker` instead. |

## Related pages

<CardGroup>
<Card title="Read API vs ingest" href="/read-vs-ingest">
Two ports and the trust boundary between `:8081` and `:8082`.
</Card>
<Card title="Capture and derive" href="/capture-and-derive">
Append-only `raw_turns` and the idempotent projection the derive worker runs.
</Card>
<Card title="Configure embeddings" href="/configure-embeddings">
Provider, model, dimensions, `tapes auth`, and disabling the embed worker.
</Card>
<Card title="Gateway capture" href="/gateway-capture">
`tapes-extproc` POSTs completed turns to ingest without sharing the serve process set.
</Card>
<Card title="CLI reference" href="/cli-reference">
Parent `tapes serve` flags and the rest of the server command surface.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Flag, `TAPES_`, and `config.toml` precedence for the keys on this page.
</Card>
</CardGroup>

---

## 20. Gateway capture

> tapes-extproc Envoy adapter: env config, gRPC recv limits, ingest POST of completed turns, and RawResponseMode for the capture ratchet.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/20-gateway-capture.md
- Generated: 2026-08-18T20:19:05.328Z

### Source Files

- `cli/tapes-extproc/main.go`
- `extproc/processor.go`
- `extproc/config.go`
- `extproc/dispatcher.go`
- `extproc/rawlane.go`
- `Dockerfile.extproc`
- `ingest/openapi.go`

---
title: "Gateway capture"
description: "tapes-extproc Envoy adapter: env config, gRPC recv limits, ingest POST of completed turns, and RawResponseMode for the capture ratchet."
---

`tapes-extproc` is a standalone gRPC sidecar that implements Envoy `envoy.service.ext_proc.v3.ExternalProcessor`. It sits on the Envoy AI Gateway request path, observes completed LLM turns, and POSTs a JSON envelope to private ingest (`POST /v1/ingest`). It is **not** a `tapes serve` subcommand: the image is built from `Dockerfile.extproc` (`cli/tapes-extproc`) so capture stays up even when the API, database, or derive worker are down.

<Warning>
Do not expose ingest through an edge gateway. Ingest trusts org identity in the envelope; `tapes-extproc` is the in-cluster writer. Point `TAPES_INGEST_URL` at the ingest base URL (cluster default `http://tapes-ingest:8090`; local `tapes serve` ingest is `:8082`).
</Warning>

## Runtime surfaces

The process binds two listeners. Version and commit come from image `ldflags`; an unstamped build logs `dev` / `HEAD`.

| Surface | Default | Protocol | Role |
| --- | --- | --- | --- |
| `TAPES_LISTEN_ADDR` | `0.0.0.0:50051` | gRPC | Envoy ext_proc `Process` RPC plus gRPC health |
| `TAPES_METRICS_ADDR` | `0.0.0.0:9090` | HTTP | Prometheus `/metrics`, `/healthz`, `/readyz` |

`/healthz` and `/readyz` always return `200` with `{"status":"ok"}` / `{"status":"ready"}`. They do **not** probe ingest. gRPC health is registered as `SERVING`.

On `SIGINT`/`SIGTERM` the gRPC server `GracefulStop`s and the HTTP server shuts down with a 5s timeout.

```mermaid
flowchart LR
  subgraph Client
    Agent[Agent or provider SDK]
  end
  subgraph Envoy["Envoy AI Gateway"]
    Filter[ext_proc filter]
    Upstream[LLM provider]
  end
  subgraph Sidecar["tapes-extproc"]
    Proc[Processor]
    Disp[Dispatcher]
  end
  subgraph Ingest["Private ingest"]
    Write["POST /v1/ingest"]
    Log[raw_turns]
  end
  Agent --> Filter
  Filter <-->|gRPC Process :50051| Proc
  Filter --> Upstream
  Proc --> Disp
  Disp -->|JSON envelope| Write
  Write --> Log
```

## Configuration

`ConfigFromEnv` reads only environment variables. Invalid integer capacities and an unrecognized `TAPES_RAW_RESPONSE_MODE` log an error and fall back to the safe default so a typo does not take the sidecar off the request path.

<ParamField body="TAPES_INGEST_URL" type="string">
Base URL of the ingest server. Default `http://tapes-ingest:8090`. The dispatcher POSTs to `{TAPES_INGEST_URL}/v1/ingest`.
</ParamField>

<ParamField body="TAPES_LISTEN_ADDR" type="string">
gRPC listen address. Default `0.0.0.0:50051`.
</ParamField>

<ParamField body="TAPES_METRICS_ADDR" type="string">
HTTP metrics/health listen address. Default `0.0.0.0:9090`.
</ParamField>

<ParamField body="TAPES_PROVIDER_MAP_FILE" type="string">
Optional YAML file mapping Envoy `x-ai-eg-selected-backend` values to provider names (`anthropic`, `openai`, …). Missing or unparsable file is a warning; the process still starts. Empty default.
</ParamField>

<ParamField body="TAPES_MAX_INFLIGHT_DISPATCHES" type="int">
Count cap on concurrent ingest POSTs. Default `100`. Non-positive or unparsable values fall back to `100`.
</ParamField>

<ParamField body="TAPES_GRPC_MAX_RECV_BYTES" type="int">
gRPC `MaxRecvMsgSize`. Default `67108864` (64 MiB). Must cover the 32 MB Anthropic Messages contract plus gRPC framing: the aigw filter buffers, so a body often arrives as one message. Bodies over this limit never reach `onRequestBody`; request size is still observed from `Content-Length` on headers.
</ParamField>

<ParamField body="TAPES_DISPATCH_BYTE_BUDGET" type="int">
Weighted semaphore on marshalled ingest payload bytes in flight. Default `268435456` (256 MiB).
</ParamField>

<ParamField body="TAPES_RAW_RESPONSE_MODE" type="string">
Response half of the envelope: `off` (default), `dual`, or `raw`. Unknown values fall back to `off`.
</ParamField>

## Request path

`Process` is a per-stream state machine: `RequestHeaders` → `RequestBody*` → `ResponseHeaders` → `ResponseBody*`. Dispatch fires once on response-body `EndOfStream` (or, on client disconnect after a `200` with buffered bytes, a salvage attempt).

```mermaid
sequenceDiagram
  participant E as Envoy
  participant P as tapes-extproc
  participant I as ingest
  E->>P: RequestHeaders
  Note over P: Resolve provider, parse X-Tapes-*, strip x-tapes-*
  P-->>E: HeadersResponse plus HeaderMutation
  E->>P: RequestBody chunks through EOS
  Note over P: Accumulate; on EOS decode request, ModeOverride if stream
  P-->>E: BodyResponse
  E->>P: ResponseHeaders :status Content-Type Content-Encoding
  E->>P: ResponseBody chunks through EOS
  alt status 200 and capturable turn
    P->>I: POST /v1/ingest JSON envelope
  else non-200, empty, ineligible, or decode/reduce fail
    P->>P: drop metric, no POST
  end
  P-->>E: BodyResponse ack
```

Forwarding never waits on ingest. Over-budget request capture stops accumulating but continues acking so Envoy still forwards the LLM call.

### Header strip

Every inbound `x-tapes-*` header is removed via `HeaderMutation` before Envoy forwards upstream. Prefix match keeps new envelope members from leaking to the provider.

### Streaming mode override

On request-body EOS, if a reducer can consume the turn and the request is `stream:true` (or the request was shed as over-budget, so `stream` cannot be parsed), the sidecar issues `ModeOverride` with `ResponseBodyMode = FULL_DUPLEX_STREAMED`. Non-streaming eligible turns stay on Envoy’s default `BUFFERED` path.

### Disconnect salvage

If the stream closes after a completed request, a `200`, and response bytes but no response EOS, the sidecar still runs `dispatchTurn`. That is the common “client closed after `message_stop`” shape under streamed ext_proc. Other incomplete streams drop as `upstream_no_response` or `client_disconnect`.

## Eligible traffic

Provider resolution prefers `x-ai-eg-selected-backend` through `TAPES_PROVIDER_MAP_FILE`, then path suffix:

| Path suffix | Provider label | Endpoint label | Reduced and posted? |
| --- | --- | --- | --- |
| `/v1/messages` | `anthropic` | `messages` | Yes — Anthropic Messages reducer |
| `/v1/responses`, `/codex/responses` | `openai` | `responses` | Yes — OpenAI Responses reducer |
| `/v1/chat/completions` | `openai` | `chat_completions` | No — `unknown_provider` |
| `/api/chat` | `ollama` | `ollama_chat` | No — `unknown_provider` |
| `/v1/messages/count_tokens` | `anthropic` | `messages_count_tokens` | No — `non_turn_request` |

Only **POST** (or a missing `:method`) on a turn path is capturable. Upstream status must be exactly **200** — not “any 2xx”. A missing `:status` drops as `missing_status`.

Reducers consume decoded textual JSON or SSE. Request and response `Content-Encoding` are undone with `capture.DecodeContentEncoding` (the same function ingest uses). A truncated compressed **request** is refused (`request_decode`). A truncated compressed **response** is salvaged and still reduced.

Eligible reducers stamp `Usage.TotalDurationNs` from the sidecar’s wall clock before dispatch. That stamp only exists on this path; ingest persists it verbatim.

## Session envelope

If any `x-tapes-*` header is present (including `x-tapes-agent-name`), the POST includes a `session` object. Otherwise the block is omitted.

| Inbound header | Envelope field |
| --- | --- |
| `x-tapes-harness-id` | `harness_id` (missing/empty → `"unknown"`) |
| `x-tapes-harness-session-id` | `harness_session_id` |
| `x-tapes-harness-version` | `harness_version` |
| `x-tapes-cwd` | `cwd` |
| `x-tapes-session-name` | `name` (percent-decoded) |
| `x-tapes-parent-harness-session-id` | `parent_harness_session_id` |
| `x-tapes-harness-metadata` | `harness_metadata` (base64url JSON; malformed drops the field only) |
| `x-paper-auth-org-id` | `org_id` |
| `x-paper-auth-subject` | `auth_subject` |

`x-paper-auth-org-id` and `x-paper-auth-subject` are **not** part of the strip set. They are meant to be populated by the gateway from validated JWT claims (`claim_to_headers`). Clients must not be allowed to set them; if the gateway does not inject them, both fields are empty strings.

`meta.thread_id` is resolved harness-neutrally:

- Claude Code: first present `x-claude-code-agent-id`
- Codex: `thread-id` only when it differs from `session-id` (root turns keep `thread_id` empty)

`x-request-id` is copied into `meta.request_id`. If absent, the sidecar synthesizes `extproc-` plus 8 hex bytes so logs and ingest dedup still have a pivot.

## Ingest POST

`Dispatcher` marshals `TurnEnvelope` and POSTs asynchronously.

<RequestExample>
```http
POST {TAPES_INGEST_URL}/v1/ingest HTTP/1.1
Content-Type: application/json
```
</RequestExample>

```json
{
  "provider": "anthropic",
  "agent_name": "claude",
  "request": {},
  "response": {},
  "raw_response": null,
  "raw_response_encoding": "",
  "raw_response_withheld": false,
  "meta": {
    "request_id": "…",
    "content_type": "text/event-stream",
    "thread_id": "",
    "method": "POST",
    "path": "/v1/messages",
    "endpoint": "messages",
    "model": "claude-sonnet-4-5",
    "model_family": "claude",
    "stream": "true",
    "content_encoding": "gzip",
    "upstream_status": 200,
    "upstream_status_class": "2xx",
    "request_bytes": 1234,
    "response_bytes": 5678,
    "elapsed_seconds": 1.23
  },
  "session": {
    "org_id": "",
    "auth_subject": "",
    "harness_id": "claude",
    "harness_session_id": "…"
  }
}
```

`request` is decoded provider JSON. `response` is the adapter’s reduced `llm.ChatResponse` unless raw-only omits it. `raw_response` is the **wire** body (still compressed if the upstream sent gzip/zstd), JSON-encoded as standard padded base64 — not the decoded reducer input.

| Dispatch rule | Behavior |
| --- | --- |
| Count semaphore full | Drop `sem_full`, no POST |
| Byte budget cannot admit marshalled size | Drop `sem_full` |
| HTTP client timeout | 30s per attempt |
| Attempts | 3, backoff 500ms then 1s |
| `200` or `202` | Accept |
| 5xx or network error | Retry; then `ingest_timeout` |
| Other 4xx (including 413) | Terminal `ingest_reject` |
| Context cancel during retry | `ingest_timeout` |

Ingest treats `meta.request_id` as the idempotency key for a retried POST of the same turn.

## RawResponseMode

The mode is the capture-path half of the ratchet that moves reduction from the adapter into ingest so every writer uses `pkg/capture`.

| Mode | Envelope `response` | Envelope `raw_response` | Typical stored fidelity |
| --- | --- | --- | --- |
| `off` | Adapter reduction | Absent | reduced |
| `dual` | Adapter reduction | Wire bytes | raw (ingest keeps the adapter reduction) |
| `raw` | Omitted (ingest reduces) | Wire bytes | raw |

Roll out `off` → `dual` → prove with `tapes raw equivalence` → `raw`. Do not switch a fleet to `raw` while its ingest image predates the shared `DecodeContentEncoding` path (zstd / stacked encodings): dual is safe in either roll order; raw-only bytes an older ingest cannot decode become unreducible archive.

### When bytes are withheld

extproc does **not** pre-drop at ingest’s 8 MiB `MaxRawResponseBytes` storage cap. Bytes between that cap and the transport budget are attached on purpose so ingest can drop-and-mark (`raw_response_dropped` / fidelity degraded).

It **does** refuse to attach past the ingest HTTP body limit (`MaxIngestBodyBytes` = 32 MiB decoded request + base64 of 8 MiB raw + 4 MiB reserve ≈ 46.67 MiB):

1. Pre-dispatch estimate (`rawResponseFits`) with a 1 MiB envelope reserve. Failure sets `raw_response_withheld` and skip reason `transport_budget`.
2. Post-marshal `enforceBodyLimit`. If the real JSON exceeds the body limit and raw bytes are present, strip them, restore the reduction on a raw-only envelope, set `raw_response_withheld`, and skip reason `oversize_stripped`.

A turn that lands reduced beats a 413 that loses request, reduction, and session. `mode=off` never sets `raw_response_withheld` — those adapters captured nothing to withhold.

If `mode=raw` but the body was not decoded with ingest’s decoder, the sidecar keeps the reduction (`encoding_not_decodable`) instead of shipping unreducible archive. On the production path that branch does not fire: a decode failure already dropped the turn as `response_decode`.

Request accumulation stops at `requestCaptureBudget` (`MaxIngestBodyBytes − 1 MiB`). Those turns drop as `request_over_budget` before marshal.

## Drop reasons

Capture-policy reasons are the shared `pkg/capture` vocabulary (same strings as other writers). Transport reasons are this adapter’s own.

| Reason | When |
| --- | --- |
| `upstream_status` | Response EOS and status ≠ 200 |
| `non_turn_request` | Path/method is not a POST chat turn |
| `request_decode` | Request `Content-Encoding` failed or truncated |
| `empty_response` | Response phase completed with zero body bytes |
| `unknown_provider` | No reducer for `(provider, endpoint)` |
| `response_decode` | Response encoding could not be decoded |
| `reducer_error` | Reducer returned an error |
| `request_over_budget` | Request exceeded the capture budget |
| `marshal_error` | Envelope JSON marshal failed |
| `ingest_reject` | Ingest 4xx |
| `ingest_timeout` | Network/5xx exhausted or context cancelled |
| `sem_full` | Count or byte admission refused |
| `client_disconnect` | Stream torn down mid-turn |
| `upstream_no_response` | Request EOS, no response bytes |
| `missing_status` | Response body without `:status` |

Empty reducer output (missing role, empty content, missing block type) is still dispatched and counted on `tapes_extproc_reducer_empty_total` so ingest validation — not a silent drop — is the authority.

## Observability

JSON logs on stdout. Useful Prometheus series on `/metrics`:

| Metric | Notes |
| --- | --- |
| `tapes_extproc_turns_captured_total` | Accepted POSTs |
| `tapes_extproc_turns_dropped_total` | `{provider,reason}` — all reasons pre-created at 0 |
| `tapes_extproc_turns_terminal_total` | Bounded labels only (`endpoint`, `stream`, `model_family`, `upstream_status_class`) |
| `tapes_extproc_turns_large_total` | Response body ≥ 4 MiB; **not** a drop |
| `tapes_extproc_body_bytes` | Request/response histograms, 256 B–64 MiB |
| `tapes_extproc_raw_response_attached_total` | `shape=dual` or `raw_only` after the marshal backstop |
| `tapes_extproc_raw_response_skipped_total` | `transport_budget` / `oversize_stripped` |
| `tapes_extproc_raw_response_fallback_total` | Raw-only kept its reduction |
| `tapes_extproc_response_decode_salvaged_total` | Truncated gzip/zstd recovered |

`thread_id`, raw paths, and model names stay in structured logs, not metric labels.

## Image

`Dockerfile.extproc` builds `./cli/tapes-extproc` with `CGO_ENABLED=0` and `GOEXPERIMENT=jsonv2` (required: shared packages compile under jsonv2; building without it decodes differently from ingest). Runtime image is `gcr.io/distroless/static:nonroot`, user `65532:65532`, `EXPOSE 50051 9090`, entrypoint `/tapes-extproc`. Dagger publishes `<registry>/tapes-extproc:<tag>`.

:::files
```text
cli/tapes-extproc/main.go   # process: env, gRPC+HTTP, signals
extproc/config.go           # ConfigFromEnv, recv/dispatch defaults
extproc/processor.go        # ext_proc state machine, reducers
extproc/dispatcher.go       # POST /v1/ingest, retry, body-limit strip
extproc/rawlane.go          # RawResponseMode + attach/skip/fallback
extproc/headers/            # X-Tapes-*, thread-id, PaperAuth
Dockerfile.extproc
```
:::

## Operations notes

<Steps>
<Step title="Run ingest as a private writer">
In a split stack: `tapes serve ingest --postgres "$TAPES_STORAGE_POSTGRES_DSN"`. Do not send capture at the read API (`:8081`).
</Step>
<Step title="Point the sidecar at ingest">
Set `TAPES_INGEST_URL` to the ingest **base** URL. Confirm the process log line `ingest_url` and `raw_response_mode` on startup.
</Step>
<Step title="Attach Envoy ext_proc">
Point the Envoy ext_proc filter at `TAPES_LISTEN_ADDR` (`:50051`). This repository does not ship the Envoy bootstrap; the sidecar only implements the processor service.
</Step>
<Step title="Verify">
`GET :9090/readyz` → `{"status":"ready"}`. A captured 200 Messages/Responses turn increments `tapes_extproc_turns_captured_total` and appears on ingest as `202`. A 413 from ingest is `ingest_reject` unless the raw-lane strip already kept the turn under the body limit.
</Step>
<Step title="Ratchet raw capture">
Leave `TAPES_RAW_RESPONSE_MODE=off` until dual traffic is stored, then prove with `tapes raw equivalence` before `raw`.
</Step>
</Steps>

<AccordionGroup>
<Accordion title="Turns never land">
Check `tapes_extproc_turns_dropped_total`. `unknown_provider` means Chat Completions, Ollama, or an unmapped backend. `sem_full` means raise `TAPES_MAX_INFLIGHT_DISPATCHES` / `TAPES_DISPATCH_BYTE_BUDGET` or ingest is too slow. `ingest_timeout` means ingest is unreachable at `TAPES_INGEST_URL`.
</Accordion>
<Accordion title="413 / reject_oversize">
Ingest Fiber `BodyLimit` is `MaxIngestBodyBytes`. Dual/raw should strip verbatim bytes first. If the reduced envelope is still oversize, the turn is sent as-is and ingest rejects it — same as pre-raw-lane behavior.
</Accordion>
<Accordion title="gRPC RESOURCE_EXHAUSTED on large requests">
Increase `TAPES_GRPC_MAX_RECV_BYTES` (default 64 MiB). Headers still arrive; bodies over the recv limit never do.
</Accordion>
<Accordion title="Empty or gzip-looking reductions">
Confirm response `Content-Encoding` decode metrics. `response_decode` is a distinct drop; salvaged gzip logs `message_stop_seen`.
</Accordion>
</AccordionGroup>

## Next

<CardGroup>
<Card title="Read API vs ingest" href="/read-vs-ingest">
:8081 is the public read surface; :8082 / in-cluster ingest is the private write path this sidecar calls.
</Card>
<Card title="Ingest API" href="/ingest-api">
`POST /v1/ingest` contract, body cap, 413 envelope, and idempotency on `meta.request_id`.
</Card>
<Card title="Prove the capture ratchet" href="/capture-ratchet">
`off` / `dual` / `raw`, `tapes raw equivalence` classes, and excluded stamp fields.
</Card>
<Card title="Capture and derive" href="/capture-and-derive">
How a posted envelope becomes `raw_turns` and then sessions, traces, and spans.
</Card>
<Card title="Split the stack" href="/split-the-stack">
Run ingest as its own process so the gateway writer has an independent failure domain.
</Card>
</CardGroup>

---

## 21. Prove the capture ratchet

> off, dual, and raw reducer modes, plus tapes raw equivalence classes, excluded fields, and the read-only comparison against stored reductions.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/21-prove-the-capture-ratchet.md
- Generated: 2026-08-18T20:20:03.778Z

### Source Files

- `cmd/tapes/raw/equivalence.go`
- `pkg/rawequiv/rawequiv.go`
- `pkg/rawequiv/report.go`
- `ingest/rawreduce.go`
- `extproc/rawlane.go`
- `docs/cli.md`

---
title: "Prove the capture ratchet"
description: "off, dual, and raw reducer modes, plus tapes raw equivalence classes, excluded fields, and the read-only comparison against stored reductions."
---

`tapes raw equivalence` is a read-only operator scan over `raw_turns` that answers one question: if this environment flipped the capture adapter from `dual` to `raw`, would ingest's server-side reducer reproduce the reduction already stored in `response`? The command decodes stored `raw_response` bytes and runs `ingest.ReduceStoredRawTurn` — the same function ingest uses on a raw-only POST — then compares that result structurally against the adapter's stored reduction. It never migrates the schema, never writes a row, and never prints prompt or completion text.

The ratchet itself is configured on **tapes-extproc** (`TAPES_RAW_RESPONSE_MODE`), not on `tapes serve proxy`. Default is `off`. `raw` is the intended end state and is gated on a clean equivalence window; the code records that no environment is flipped to `raw` today.

## The ratchet

Two capture paths that each reduce independently can disagree. The shared `pkg/capture` reducers cannot prevent that while a second reducer still runs in the adapter. The end state is one reducer, server-side, for every wire path that can be reduced.

```mermaid
stateDiagram-v2
    [*] --> off
    off --> dual: ship reduction plus bytes
    dual --> dual: prove with tapes raw equivalence
    dual --> raw: zero blocking classes and stamp coverage is acceptable
    raw --> raw: ingest reduces only
```

| Mode | Adapter envelope | Who reduces | Stored fidelity |
| --- | --- | --- | --- |
| `off` | Reduction only. No verbatim bytes leave the process. | Adapter | `reduced` |
| `dual` | Reduction **and** verbatim upstream bytes. | Adapter (ingest keeps that reduction) | `raw` |
| `raw` | Verbatim bytes only. | Ingest, via `ReduceStoredRawTurn` | `raw` |

<ParamField body="TAPES_RAW_RESPONSE_MODE" type="off \| dual \| raw">
Environment variable on tapes-extproc. Empty or omitted is `off`. An unrecognized value is an error at parse time; `ConfigFromEnv` logs it and falls back to `off` so a typo does not stop serving traffic. Case-insensitive.
</ParamField>

`dual` is the proving rung: ingest still persists the adapter's live reduction (the adapter consumed the stream and may have seen framing the stored bytes no longer show), and stores the bytes beside it. Operator-visible derived content does not change. The bytes make "would `raw` have produced this row?" answerable offline.

## Envelope decisions on tapes-extproc

`decideRawLane` resolves mode plus per-turn size and decode facts.

- Modes that send bytes (`dual`, `raw`) attach `raw_response` only when the envelope still fits ingest's body limit after a 1 MiB reserve for the reduced response, meta, session, and JSON scaffolding. Base64 expansion of the `[]byte` field is what is measured.
- The adapter does **not** pre-drop at ingest's 8 MiB storage cap. Bytes between that cap and the transport budget are attached so ingest performs the drop-and-mark (`raw_response_dropped`, fidelity `degraded`). Dropping first would make the row look like a producer that never captured bytes.
- If the body would exceed `ingest.MaxIngestBodyBytes`, the adapter withholds bytes (`raw_response_withheld`) and keeps the reduction. Losing the whole turn at the 413 boundary is worse than losing only the verbatim lane.
- Under `raw`, if the receiver cannot decode the bytes, the turn degrades to dual rather than shipping unreducible archive (`encoding_not_decodable`). On the current dispatch path the body was already decoded with ingest's decoder, so this branch is a safety interlock for a future caller.
- Truncated/salvaged streams are not forced back to dual. Ingest salvages on the same rule as extproc (partial output plus `io.ErrUnexpectedEOF`) and reduces the result.
- A post-marshal backstop can still strip bytes (`oversize_stripped`) if the reserve was optimistic.

<Warning>
Do not switch an environment to `raw` while its ingest image predates the current decoder (zstd and stacked encodings). Under `dual` a decode mismatch is harmless because the reduction still ships. Under `raw` those bytes store as unreducible archive.
</Warning>

## Run the proof

The command lives on the **tapes** server binary, not on `tapesctl`. It needs a PostgreSQL DSN (`--postgres` or `storage.postgres_dsn`) and nothing else.

<ParamField body="--postgres" type="string" required>
PostgreSQL connection string. Bound to `storage.postgres_dsn`.
</ParamField>

<ParamField body="--since" type="duration | RFC 3339" default="24h">
Lower bound on `received_at`. A Go duration (`24h`, `168h`) is measured back from now (negative durations are flipped). An RFC 3339 instant pins a fixed window for re-runs after a reducer change.
</ParamField>

<ParamField body="--limit" type="int" default="1000">
Maximum wire turns, newest first (`received_at DESC, id DESC`). `<= 0` falls back to 1000.
</ParamField>

<ParamField body="--session" type="string">
Restrict to one `harness_session_id`.
</ParamField>

<ParamField body="--json" type="bool" default="false">
Emit the report as indented JSON instead of text.
</ParamField>

<ParamField body="--max-diffs" type="int" default="10">
Maximum structural differences recorded per divergent turn (`rawequiv.DefaultMaxDiffs`).
</ParamField>

<ParamField body="--max-report" type="int" default="20">
Maximum blocking turns listed in detail. Counts stay exact; extra blocking rows set `divergences_truncated`.
</ParamField>

<Steps>
<Step title="Collect dual traffic">
Set `TAPES_RAW_RESPONSE_MODE=dual` on tapes-extproc and capture a representative mix of providers, models, streaming vs one-shot, and encodings. Rows captured under `off` have no bytes and cannot prove anything.
</Step>
<Step title="Scan the window">
<CodeGroup>
```bash title="In-cluster against the tenant database"
kubectl exec -n <tenant-ns> deploy/tapes-api -- \
  tapes raw equivalence --since 24h --limit 5000
```

```bash title="Locally against a forwarded database"
tapes raw equivalence \
  --postgres "postgres://user:pass@127.0.0.1:15432/tapes" \
  --since 24h --limit 5000 --json
```
</CodeGroup>
</Step>
<Step title="Read the verdict, then stamp coverage">
A clean verdict with `equivalent > 0` and zero blocking classes is necessary. It is not sufficient: check whether `usage.total_duration_ns` would restore from `elapsed_seconds` or fall back to empty. Then decide whether to set `TAPES_RAW_RESPONSE_MODE=raw`.
</Step>
</Steps>

### What the scan reads

Only `source = 'wire'` rows. Transcript uploads (`POST /v1/ingest/transcript`) have no upstream bytes and are unaffected by capture mode.

The SELECT is local to the command (not a sqlc query). It does not join attribution corrections: those rewrite session membership, not bytes, reduction, or capture meta.

The pool is opened with `default_transaction_read_only=on` and `MaxConns = 1`. It does **not** go through `postgres.NewDriver` / `postgres.Open`, which run golang-migrate. Pointing the tool at a live tenant database therefore cannot mutate schema as a side effect. Every statement is a SELECT. Rows are streamed one at a time because a `raw_response` can be up to 8 MiB.

## Equivalence classes

Every examined row lands in exactly one class. The classes partition the window.

| Class | Blocks flip | Meaning |
| --- | --- | --- |
| `equivalent` | no | Re-reduction matches the stored reduction after tolerated fields are pruned. This is the count that supports a ratchet step. |
| `divergent` | **yes** | Both reductions exist and differ outside the two tolerated fields. |
| `undecodable` | **yes** | Stored bytes cannot be decoded under `raw_response_encoding`. Under `raw` the row would carry no reduction. |
| `unreducible` | **yes** | Bytes decoded but the reducer rejected them (missing `meta.content_type` on an Anthropic SSE body is the sharp case). Under `raw` the row would carry no reduction. |
| `no_reducer` | **yes** | No server-side reducer is registered for `provider`. `ollama` is in this bucket today. |
| `skipped_no_raw` | no | No verbatim bytes and none reported lost — producer was not sending raw when the turn was captured. |
| `skipped_dropped` | no | Bytes existed and were not kept (`raw_response_dropped`: producer withheld at the transport limit, or ingest dropped over the 8 MiB cap). Count these; a raw-only deployment cannot reconstruct them. |
| `skipped_no_reduction` | no | Bytes present but no adapter reduction (already raw-only, or stored JSON is not a `ChatResponse`). No second opinion to compare. |

Classification order is deliberate: `raw_response_dropped` is checked before absent bytes so a limit that bit is not reported as "producer never sent raw".

`undecodable`, `unreducible`, and `no_reducer` block alongside `divergent` because their consequence under `raw` is worse: a divergent row still has a reduction; these three store none. Ingest's `reduceRawOnly` does not fail the POST when reduction fails — the bytes land — but recovering the turn is possible in principle and not in practice. The deriver reads the reduced `response` column and does not select `raw_response`. A later reducer fix does not revive those rows on a plain re-derive.

A window with zero blocking rows and zero `equivalent` rows prints `VERDICT: no comparable turns in this window. Nothing was proven.` Dual traffic is a prerequisite, not an optional extra.

## What "equivalent" means

Comparison is structural, not byte-level. Postgres `jsonb` normalizes key order, whitespace, duplicate keys, and number formatting; the recomputed side is a fresh Go marshal. Both sides are decoded to a generic JSON tree so the storage round-trip is not reported as a divergence. Numbers compare as `float64` (so `1e2` equals `100`).

Exactly two fields are removed from both trees before the walk:

| Path | Why it is tolerated |
| --- | --- |
| `created_at` | Reducers stamp `CreatedAt` with `time.Now()` at reduction time (Anthropic). Two reductions of identical bytes taken at different instants differ by construction. OpenAI Responses copies the upstream `created_at`; the exclusion is still uniform. |
| `usage.total_duration_ns` | Proxy-measured wall clock. Neither Anthropic nor OpenAI put call duration in the body. A reduction of stored bytes cannot recover it from the payload. Under `raw`, ingest re-stamps from `meta.elapsed_seconds` (float64 seconds), which is a different rounding than the adapter's nanoseconds. |

After those paths are pruned, an emptied `usage` object is dropped on both sides so the adapter's duration-only `Usage` allocation does not surface as object-vs-absent.

Everything else is compared strictly: model, message role, every content block, stop reason, remaining usage counters, extra diagnostics, and any echoed raw body. A third difference is a `divergent` row, not a new tolerance.

### Diff shape

A difference is a JSON path plus a **rendering**, never the raw value.

| `kind` | Meaning |
| --- | --- |
| `missing_in_recomputed` | Stored reduction has a field the server-side path would lose. |
| `missing_in_stored` | Server-side reduction has a field the adapter did not store. |
| `type_mismatch` | Same path, different JSON types. |
| `value_mismatch` | Same path and type, different value. |
| `length_mismatch` | Arrays of different length; overlapping elements are also walked. |

String values print verbatim only on an allowlist of provider-controlled vocabulary (`model`, `stop_reason`, `done`, `message.role`, `message.content[].type`, `message.content[].name`, `message.content[].tool_use_id`, and a small `extra.*` set). Any other string is `string(len=N,sha256=abcd…)`. Numbers, bools, array lengths, and object key lists print as shape.

A truncated stored body that was salvaged is flagged on the outcome. A divergence on that row is expected: the adapter saw the whole stream live.

## Stamp coverage is a different question

Both excluded fields are ones `raw` restores from capture `meta`, not from the bytes. The report therefore tallies, over rows that were actually re-reduced, which source would have supplied each stamp.

Duration (`usage.total_duration_ns`):

| Source | Field | Behavior |
| --- | --- | --- |
| `elapsed_seconds` | `meta.elapsed_seconds` | Stamped when the value is finite, `> 0`, and `<= 7d`. Overwrites any provider-internal duration so every turn's duration means wall clock at the capture point. |
| `fallback` | none | Duration left empty. Derived span `duration_ns` would be NULL. |

CreatedAt (`created_at`), most precise first:

| Source | Field | Behavior |
| --- | --- | --- |
| `captured_at` | `meta.captured_at` | Completion instant (RFC 3339 Nano, same parser `derive.CapturedAt` uses). No released producer sends this yet. |
| `ts_request` | `meta.ts_request` | Request instant, plus `elapsed_seconds` when usable. Backfill rows already carry `ts_request`. |
| `fallback` | reducer default | OpenAI Responses already has the upstream timestamp; Anthropic has reduction-time `time.Now()` (ingest time under `raw`). |

A malformed present timestamp is logged and counted; it never rejects the turn.

<Warning>
A window can be perfectly `equivalent` and still lose data on the flip. If `usage.total_duration_ns` shows `fallback=N`, those N turns have no usable `meta.elapsed_seconds`. Check stamp coverage before changing `TAPES_RAW_RESPONSE_MODE`.
</Warning>

## Server-side reduction

`ReduceStoredRawTurn` is a pure function of the stored row: decode under `raw_response_encoding` (`capture.DecodeContentEncoding`; empty encoding is identity), reduce with the provider's `pkg/capture` reducer, then restore the two capture-side stamps. No database, no clock beyond what the reducer itself stamps, no metrics. Ingest emits metrics from the returned `StampSource` values; the CLI reports the same facts.

Registered reducers:

| Provider | Reducer |
| --- | --- |
| `anthropic` | `capture.NewAnthropicReducer()` |
| `openai` | `capture.NewOpenAIResponsesReducer()` |

There is no global registry. A provider with no entry never gets a server-side reduction. **Ollama traffic cannot move to `raw` until a reducer exists**; those rows class `no_reducer` and block the window.

`reduceRawOnly` on ingest is a no-op unless the payload has verbatim bytes **and** `ReducedResponseAbsent` is true. An adapter that already reduced keeps its result. That predicate is shared with the prover so the flip is measured on the same population ingest would rewrite.

## Limits that produce skipped_dropped

| Limit | Owner | Outcome |
| --- | --- | --- |
| `MaxRawResponseBytes` = 8 MiB | ingest persist | Bytes dropped, `raw_response_dropped = true`, fidelity `degraded`. Reduction, request, and session attribution still stored. |
| `MaxIngestBodyBytes` = 32 MiB request + 8 MiB×4/3 raw + 4 MiB reserve | ingest HTTP + extproc attach check | Whole body rejected (413) if it still arrives oversized. Extproc withholds bytes first so the turn is not lost. |
| Envelope reserve 1 MiB | extproc | Slack for reduction + meta + session; Dispatch re-checks the marshalled payload. |

Fidelity rollup on derived traces is worst-tier: `degraded` < `reduced` < `raw`. `skipped_dropped` does not fail the equivalence process, but a large count is a reason not to treat the window as representative of a raw-only deployment.

## Report and exit status

Text output always reprints the tolerated fields so a pasted report carries its own definition of equivalence.

```text
raw-response equivalence over 412 wire turn(s)
  window: since=24h limit=5000 session=-

  equivalent             398
  skipped_dropped          9
  skipped_no_raw           5

capture-side stamps mode=raw could restore (398 re-reduced turn(s)):
  usage.total_duration_ns  elapsed_seconds=390 fallback=8
  created_at               ts_request=398

equivalence excludes, by design:
  created_at
  usage.total_duration_ns

VERDICT: 398 turn(s) re-reduce identically. This window supports the ratchet.
```

If any class is blocking:

- stdout still holds the full report (including up to `--max-report` blocking rows with path + shape diffs, never body content).
- process exits non-zero with `raw-response equivalence failed: N of M turn(s) would not survive mode=raw`.

`--json` marshals the same `Report`: `window`, `counts`, `total`, `divergences`, `divergences_truncated`, `stamp_coverage`, `tolerated`.

Use it as a CI gate after a reducer change and before flipping `TAPES_RAW_RESPONSE_MODE` from `dual` to `raw`.

## Failure modes

| Symptom | What to check |
| --- | --- |
| `Nothing was proven` | Window is `off` traffic, transcript-only, or `--session` filtered away every dual row. Widen `--since` / `--limit` or confirm the adapter is on `dual`. |
| `undecodable` | Encoding the current ingest build cannot decode (`br` is not supported). Or bytes corrupt for the recorded encoding. Do not flip `raw` until decode matches. |
| `unreducible` | Often empty `meta.content_type` on a streaming Anthropic body: the reducer does not sniff SSE vs one-shot. Under `dual` the live reduction hides this. |
| `no_reducer` | `ollama` (or any unregistered provider) in the window. Keep those adapters on `off`/`dual`, or exclude them from the environment you intend to flip. |
| `divergent` | Real reducer disagreement. `missing_in_recomputed` is the direction that loses a field on the flip. |
| Equivalent but empty durations after a hypothetical flip | `fallback` on `usage.total_duration_ns`. Producers must send `meta.elapsed_seconds`. |
| `equivalence requires a postgres DSN` | Pass `--postgres` or set `storage.postgres_dsn`. |
| Command appears to write | It does not. If you see migrations, you invoked a different `tapes` entry point. |

## Next

<CardGroup>
<Card title="Gateway capture" href="/gateway-capture">
tapes-extproc env, gRPC recv limits, ingest POST of completed turns, and RawResponseMode.
</Card>
<Card title="CLI reference" href="/cli-reference">
tapes raw equivalence among the server commands. tapesctl is not this binary.
</Card>
<Card title="Ingest API" href="/ingest-api">
POST /v1/ingest, body cap, and 413 reject_oversize — the transport side of withheld bytes.
</Card>
<Card title="Capture and derive" href="/capture-and-derive">
Append-only raw_turns, the lossy reduction beside raw_response, and the deriver that reads response not raw_response.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Wrong-port capture, ingest 413, and other operator failures adjacent to this gate.
</Card>
</CardGroup>

---

## 22. Troubleshooting

> Wrong-port capture, missing OpenAI embed keys, ingest 413, attribution-repair 200 vs 202, --wipe data loss, and GOEXPERIMENT=jsonv2 build failures.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/22-troubleshooting.md
- Generated: 2026-08-18T20:19:38.068Z

### Source Files

- `docs/introduction.md`
- `CONTRIBUTING.md`
- `ingest/errorhandler.go`
- `pkg/embeddings/openai/openai.go`
- `api/admin_handlers.go`
- `cmd/tapes/local/local.go`
- `docs/apis.md`

---
title: "Troubleshooting"
description: "Wrong-port capture, missing OpenAI embed keys, ingest 413, attribution-repair 200 vs 202, --wipe data loss, and GOEXPERIMENT=jsonv2 build failures."
---

Most operator-visible failures in tapes come from mixing the three listen surfaces, starting the embedder without a key, exceeding the ingest body cap, misreading an attribution-repair status, wiping the local Postgres volume, or building without `GOEXPERIMENT=jsonv2`.

| Symptom | Likely cause | First check |
| --- | --- | --- |
| `tapesctl start` / `capture` / `sync` reports success, `tapesctl sessions list` is empty | Capture pointed at `:8081` instead of `:8082` | `tapesctl config get tapes-url` and the `--tapes-url` on the capture command |
| `tapes serve` exits with `creating embedder: OPENAI_API_KEY is required for openai embeddings` | `embedding.provider = openai` and no key in the environment or `credentials.toml` | `tapes auth --list` and `echo $OPENAI_API_KEY` |
| `GET /v1/search/spans` returns `503` | Search not configured, or no embed pass has created the projection | `tapes serve` (or `tapes serve embed-worker`) and `tapes status` |
| `POST /v1/ingest` or `/v1/ingest/transcript` returns `413` | Body over `MaxIngestBodyBytes` (~46.67 MiB) | Ingest logs `ingest body over limit` and `tapes_ingest_writes_total{status="reject_oversize"}` |
| Attribution repair returns `202` | Correction committed; synchronous re-derive did not finish | `projections_pending` — do **not** retry the repair |
| Sessions vanish after `tapes local down` | `--wipe` deleted the Postgres data directory | Confirm the command you ran; `tapes local down` without `--wipe` keeps data |
| `go build` / merkle canonicalize fails in a source checkout | `encoding/json/v2` and `jsontext` need `GOEXPERIMENT=jsonv2` | `make build-local` or `nix develop` |

Default local ports: proxy `:8080`, read API `:8081`, private ingest `:8082`. `tapes` owns the database; `tapesctl` is the client.

## Wrong-port capture

`tapesctl` stores one URL (`tapes-url`). Read commands need the read API. Capture commands (`start`, `capture`, `sync`) need the private ingest API. Pointing capture at `:8081` can report success and store nothing.

| Port | Surface | Who should call it |
| --- | --- | --- |
| `:8080` | Provider-compatible proxy | Agent / LLM client (`ANTHROPIC_BASE_URL`, OpenAI base URL, Ollama `/api/chat`) |
| `:8081` | Read API | `tapesctl sessions`, `search`, `export`, `seed`, `skill`; `tapes status --api-target` |
| `:8082` | Private ingest | `tapesctl start`, `capture`, `sync`; gateway / `tapes-extproc` `POST /v1/ingest` |

<Warning>
A capture pointed at the read port is not a hard error. The client talks HTTP to the wrong contract and the session never lands in `raw_turns`.
</Warning>

Configure the URL most commands want, then override ingest on capture:

```bash
tapesctl config set tapes-url http://localhost:8081
tapesctl start claude --tapes-url http://localhost:8082
tapesctl start codex --tapes-url http://localhost:8082
tapesctl sync --tapes-url http://localhost:8082
```

Precedence on the client is `--tapes-url`, then `TAPES_URL`, then `~/.tapes/config.toml`. With none of the three, a command that needs a server fails rather than guessing a host. The client always reads `~/.tapes/config.toml`; a project-local `.tapes/` configures the **server** only.

<Steps>
<Step title="Confirm the stack is up">
```bash
tapes local status
curl -sS http://localhost:8081/ping
curl -sS http://localhost:8082/ping
tapes status
```
</Step>
<Step title="Confirm which URL the client will use">
```bash
tapesctl config get tapes-url
tapesctl config path
```
Read commands should hit `:8081`. Capture commands must pass `--tapes-url http://localhost:8082` unless that is the stored URL.
</Step>
<Step title="Capture, then list on the read API">
```bash
tapesctl start claude --tapes-url http://localhost:8082
tapesctl sessions list --tapes-url http://localhost:8081
```
`start` prints the harness session id. That is **not** the UUID `tapesctl sessions get` takes. Find the derived session in the list.
</Step>
</Steps>

Fixed-port capture still writes ingest on `:8082`. Traffic to `:8080` is the provider proxy, not the Tapes HTTP contract.

## Missing OpenAI embed keys

`tapes auth` stores keys for **server-side** provider calls (span embedding and skill generation). Capture is transparent: the agent brings its own credentials. Switching embeddings to OpenAI does not capture with that key.

<ParamField body="embedding.provider" type="string">
`ollama` (default) or `openai`.
</ParamField>
<ParamField body="OPENAI_API_KEY" type="string">
Environment fallback used by `openai.NewEmbedder` when the config API key is empty.
</ParamField>

When `embedding.provider` is `openai`, `tapes serve` / `tapes serve api` / `tapes serve embed-worker` build the embedder at process start. A missing key fails boot:

```text
creating embedder: OPENAI_API_KEY is required for openai embeddings
```

Key resolution:

1. `credentials.APIKeyForProvider("openai", configDir)` loads `.tapes/credentials.toml` **only if** `OPENAI_API_KEY` is unset.
2. If the env var is set, the stored file is ignored and `NewEmbedder` reads `OPENAI_API_KEY`.
3. If both are empty, construction fails with the error above.

<Tabs>
<Tab title="Stored key">
```bash
tapes auth openai
tapes config set embedding.provider openai
tapes serve
```
</Tab>
<Tab title="Environment">
```bash
export OPENAI_API_KEY=sk-...
tapes config set embedding.provider openai
tapes serve
```
</Tab>
</Tabs>

`tapes auth openai` writes `credentials.toml` under the resolved `.tapes/` directory (never `config.toml`). Supported providers: `openai`, `anthropic`. `tapes auth --list` shows stored providers; `tapes auth --remove openai` deletes the OpenAI entry.

Switching provider remaps inherited local defaults: OpenAI uses `https://api.openai.com`, `text-embedding-3-large`, and `1024` dimensions unless you set those keys explicitly. Ollama stays `http://localhost:11434`, `embeddinggemma`, `768`. The embed worker's model and dimensions must match the pgvector table.

<Note>
`tapes auth` warns that `sk-proj-...` keys may lack required API scopes. Prefer a service-account key (`sk-svcacct-...`) when OpenAI rejects the call.
</Note>

### Search 503 after a successful serve

Boot can succeed with an embedder while search still returns `503`:

| Error | Meaning |
| --- | --- |
| `span search is not configured: embedder and span embedding store are required` | No embedder / span store on the API (split `tapes serve api` without vector config, or `--embed-spans=false` plus no store). |
| `span embeddings not initialized: run the embed pass (tapes serve embed-worker or tapes dev embed-spans)` | Writer has not created the projection yet. |

```bash
curl -sS 'http://localhost:8081/v1/search/spans?query=retry'
# 503 until an embed pass has run

tapes serve embed-worker --postgres "$TAPES_STORAGE_POSTGRES_DSN"
curl http://localhost:11434/api/tags   # default Ollama embeddings
```

Empty search results are not an error. A configured but uninitialized surface is `503`.

If native Ollama is installed but stopped, `tapes local up` does not start it. Start `ollama serve` (or the Ollama app) and `ollama pull embeddinggemma:latest`.

## Ingest 413 (`reject_oversize`)

:::endpoint POST /v1/ingest
Request bodies are capped at `MaxIngestBodyBytes`. An oversize POST is rejected before parse.

**Limit.** `MaxDecodedRequestBytes` (32 MiB) + `MaxRawResponseBytes` (8 MiB) encoded at 4/3 + 4 MiB reserve ≈ **46.67 MiB** (`48933546` bytes). The same Fiber `BodyLimit` applies to `POST /v1/ingest/transcript`.

**Response.** HTTP `413` with the standard ingest JSON envelope:

```json
{"error":"request body exceeds the ingest size limit"}
```

**Metrics / logs.** One sample of `tapes_ingest_writes_total{provider="unknown",status="reject_oversize"}` and one `ingest body over limit` warn with `content_length`, `limit`, and `path`. Provider is `unknown` because the body is never parsed. The accepted-size histogram is not updated (`bodyBytes` is 0).

**Scope.** Only POST `/v1/ingest` and `/v1/ingest/transcript` take this JSON handler. A handler-returned 413 that is not `fiber.ErrRequestEntityTooLarge` keeps Fiber's default response and is not counted as `reject_oversize`.
:::

A 413 means the turn did **not** land. Shrink the payload (gateway adapters already drop or truncate `raw_response` to stay under the envelope). Do not retry the same oversize body.

<Info>
A turn that is accepted (`202` `{"status":"accepted"}`) is on the append-only `raw_turns` log. Derivation is asynchronous. That ingest `202` is unrelated to attribution-repair `202`.
</Info>

The capture proxy uses the same `MaxIngestBodyBytes` as its own request `BodyLimit`. Independently, ingest drops a stored `raw_response` larger than 8 MiB and still persists the reduced turn — that path is not a 413.

## Attribution-repair 200 vs 202

:::endpoint POST /v1/admin/raw-turns/attribution-repair
Operator route on the **read** API (`:8081`). Records an append-only correction for exactly one raw turn without modifying `raw_turns`, then synchronously re-derives the previous and effective sessions.

Select the row with 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 empty, and must not equal `harness_session_id`. `org` is not accepted from the body (single-tenant sentinel).
:::

<RequestExample>
```bash
curl -sS -X POST http://localhost:8081/v1/admin/raw-turns/attribution-repair \
  -H 'Content-Type: application/json' \
  -d '{
    "paper_proxy_request_id": "proxy-1",
    "harness_id": "codex",
    "harness_session_id": "child",
    "thread_id": "thread",
    "reason": "hook evidence"
  }'
```
</RequestExample>

| Status | Meaning | Retry? |
| --- | --- | --- |
| `200` | Repair applied. Projection rebuild finished. | No |
| `202` | Correction **committed and effective**. Synchronous rebuild did not finish. `projections_pending` names stale sessions. The derive worker converges them. | **No** — retrying records another correction |
| `400` | Invalid selector or replacement attribution | Fix the body |
| `404` | Raw turn not found | Check the selector |
| `409` | Selector is ambiguous | Narrow to one row |
| `500` | Unexpected storage failure | Investigate logs |
| `501` | Driver does not implement repair (e.g. in-memory) | Use Postgres |

<ResponseField name="recorded" type="boolean">
Correction row was written.
</ResponseField>
<ResponseField name="previous" type="object">
Attribution before the overlay.
</ResponseField>
<ResponseField name="effective" type="object">
Replacement attribution now in force.
</ResponseField>
<ResponseField name="projections_pending" type="array">
Sessions whose rebuild is outstanding. Present on `202`. Already marked dirty in the correction transaction.
</ResponseField>
<ResponseField name="source_cleanup_pending" type="boolean">
Best-effort delete of an emptied source-session row failed. Cosmetic: the leftover row anchors no effective turns. Independent of the status code. **Nothing retries it.**
</ResponseField>

<Warning>
Treat `202` as success of the repair, not as a failed write. A `500` would invite a redundant retry; `202` exists so operators do not do that.
</Warning>

Wait for the derive worker (`tapes serve` or `tapes serve derive-worker`). `POST /v1/admin/derive/run` rebuilds projections from `raw_turns` if you need a manual pass. Seeding via `POST /v1/admin/seed/demo` is idempotent; `"overwrite": true` is rejected.

## `--wipe` data loss

<ParamField body="--wipe" type="boolean">
On `tapes local down` only. Deletes the local Postgres data directory after removing containers. Default `false`.
</ParamField>

```bash
tapes local down            # stop/remove tapes-local-postgres and tapes-local-ollama; keep data
tapes local down --wipe     # also os.RemoveAll(<tapesDir>/postgres)
```

The data directory is `<resolved-.tapes>/postgres` (project `.tapes/`, `--config-dir`, or `~/.tapes/`). That volume holds captured `raw_turns` and the derived sessions/traces/spans. `--wipe` is permanent for that local database.

`tapes local down` without `--wipe` is the normal stop. `tapes local up` after a wipe starts empty Postgres; re-seed with `tapesctl seed --tapes-url http://localhost:8081`.

`tapes local` requires Docker. Default images: Postgres `public.ecr.aws/g4e5l3z3/papercomputeco/postgres:17.7-pgduckdb-1.1.1` (user/password/db `tapes`, port `5432`) and `ollama/ollama:latest` (port `11434`) unless native Ollama is already running.

## `GOEXPERIMENT=jsonv2` build failures

Go module version is **1.26.1**. Merkle identity (`pkg/merkle`) imports `encoding/json/v2` and `encoding/json/jsontext` and RFC 8785-canonicalizes hash input so content hashes are stable across runs. As of Go 1.26.x that path requires `GOEXPERIMENT=jsonv2`.

<CodeGroup>
```bash makefile
make build-local
# CGO_ENABLED=0 GOEXPERIMENT=jsonv2 go build -o ./build/ ./cli/tapes
```

```bash nix
nix develop    # flake exports GOEXPERIMENT=jsonv2
make build-local
```

```bash wrong
go build ./cli/tapes
# fails: json v2 / jsontext are experiment-gated
```
</CodeGroup>

Use `make` for development. `make test`, `make check`, and `make format` run through Dagger and need Docker. Do not point DB-backed tests at an arbitrary stock Postgres; missing `pgvector` / `pg_duckdb` looks like application failure.

`make install` copies via `install(1)` (new inode) so an in-place overwrite does not invalidate a running Mach-O signature on macOS.

## Diagnostic commands

```bash
tapes version
tapes local status
tapes status --api-target http://localhost:8081
tapes config list
tapes auth --list

curl -sS http://localhost:8081/ping
curl -sS http://localhost:8082/ping
curl -sS http://localhost:8081/metrics
curl -sS http://localhost:8082/metrics   # look for reject_oversize

tapesctl sessions list --tapes-url http://localhost:8081
```

`tapes status` probes the **read** API (`client.api_target`, default `http://localhost:8081`). It does not prove ingest is reachable.

## Related pages

<CardGroup>
<Card title="Capture an agent" href="/capture-an-agent">
Point Claude, Codex, pi, or a generic client at ingest `:8082`.
</Card>
<Card title="Read API vs ingest" href="/read-vs-ingest">
Two contracts, two ports, and the trust boundary.
</Card>
<Card title="Ingest API" href="/ingest-api">
`POST /v1/ingest`, body cap, JSON error envelope, `reject_oversize`.
</Card>
<Card title="Configure embeddings" href="/configure-embeddings">
`ollama` vs `openai`, `tapes auth`, embed worker.
</Card>
<Card title="CLI reference" href="/cli-reference">
`tapes local`, `serve`, `status`, `auth`, `config`.
</Card>
<Card title="Contribute" href="/contribute">
Nix / Make, `GOEXPERIMENT=jsonv2`, `make test`.
</Card>
</CardGroup>

---

## 23. Contribute

> Nix or Make build, make test through Dagger, OpenAPI CONTRACT seals, sqlc generate, PR title labels, and tapes vs tapesctl ownership.

- Page Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/pages/23-contribute.md
- Generated: 2026-08-18T20:19:51.217Z

### Source Files

- `CONTRIBUTING.md`
- `makefile`
- `flake.nix`
- `docs/development.md`
- `cmd/tapes/dev/dev.go`
- `cmd/tapes/dev/checkopenapi.go`
- `AGENTS.md`

---
title: "Contribute"
description: "Nix or Make build, make test through Dagger, OpenAPI CONTRACT seals, sqlc generate, PR title labels, and tapes vs tapesctl ownership."
---

This repository is the **server**: `tapes` owns the database, the services, and the operator CLI. Development is Make-first (`make help`); Dagger provisions the pinned Postgres image and runs the checks. The Nix flake is the recommended toolchain. Client commands live in [`tapesctl`](https://github.com/papercomputeco/tapesctl) — do not re-add them here.

## Prerequisites

| Requirement | Used for |
| --- | --- |
| Go 1.26+ (`go 1.26.1` in `go.mod`) | Local `make build-local` |
| Docker | `make format`, `make check`, `make test`, and every other Dagger target |
| Nix flakes (recommended) | Pinned Go 1.26, Dagger, `sqlc`, `hurl`, `GOEXPERIMENT=jsonv2` |
| PostgreSQL with pgvector + pg_duckdb | Local `tapes serve` / `tapes local up` |
| Ollama (optional) | Default local embeddings |

Merkle hashing requires `GOEXPERIMENT=jsonv2`. `make build-local` and the Nix shell set it. Building without it fails.

<Warning>
Do not point tests at an arbitrary stock Postgres container. Missing pgvector / pg_duckdb looks like application failure. `make test` binds `public.ecr.aws/g4e5l3z3/papercomputeco/postgres:17.7-pgduckdb-1.1.1` and injects `TEST_POSTGRES_DSN`.
</Warning>

## Dev environment

<Tabs>
<Tab title="Nix (recommended)">

```bash
git clone https://github.com/papercomputeco/tapes.git
cd tapes
nix develop          # or: direnv allow  (.envrc is `use flake . --impure`)
make help
```

The flake `devShells.default` installs `go_1_26`, `gotools`, `sqlc`, `gnumake`, Dagger, `git`, and `hurl`, and exports `GOEXPERIMENT=jsonv2`. The shell hook prints `make help`.

</Tab>
<Tab title="Manual">

Install Go 1.26+ and Docker yourself, then:

```bash
make build-local
./build/tapes local up
```

`make format` / `make check` / `make test` still need Docker.

</Tab>
</Tabs>

## Build and run

<Steps>
<Step title="Build the local binary">

```bash
make build-local
```

This is `CGO_ENABLED=0 GOEXPERIMENT=jsonv2 go build` of `./cli/tapes` into `./build/tapes`. ldflags stamp `pkg/utils.Version`, `Sha`, `Buildtime` from `git describe` / `git rev-parse HEAD` / UTC time.

Install into `$(go env GOBIN)` with `make install`. That target uses `install -m 0755` (temp file + rename), not `cp`: overwriting the binary in place invalidates the running Mach-O code signature on macOS and SIGKILLs the next invocation.

</Step>
<Step title="Start local dependencies and the stack">

```bash
./build/tapes local up
./build/tapes serve
```

`tapes local` is a group (`up` / `status` / `down`), not a no-arg start. `tapes local up` writes Postgres / pgvector / Ollama settings into the active `.tapes/config.toml`.

</Step>
<Step title="Seed and list through the client">

Seeding is a **client** operation:

```bash
tapesctl seed --tapes-url http://localhost:8081
tapesctl sessions list --tapes-url http://localhost:8081
```

Reset demo data with a fresh database behind the API (`tapes local down --wipe && tapes local up`), not by re-running seed against the same DB as if it were idempotent cleanup.

</Step>
</Steps>

<CodeGroup>

```bash title="Local build"
make build-local
./build/tapes version
```

```bash title="Cross-platform release artifacts"
make build          # dagger call build-release … export --path ./build
```

```bash title="Format (applies fixes)"
make format         # dagger call fix-lint export --path .
```

</CodeGroup>

`make clean` removes `./build`. Default goal is `make help`.

## Tests and Dagger checks

Always go through Make. `make test` is `dagger call test`: a Go 1.26-bookworm container (`CGO_ENABLED=0`, `GOEXPERIMENT=jsonv2`) with the pinned Postgres service bound as `postgres` and `TEST_POSTGRES_DSN=host=postgres user=tapes password=tapes dbname=tapes port=5432 sslmode=disable`. The suite is `go test -count=1 -v ./...`.

`make test-run-id` passes `--run-id="$(date +%s)"` to bypass Dagger's exec cache.

| Target | Dagger function | What it does |
| --- | --- | --- |
| `make test` | `test` | Full `go test ./...` with Postgres |
| `make check` | `dagger check` | Every `+check` function (see below) |
| `make format` | `fix-lint` | golangci-lint v2.8.0 with `--fix` |
| `make parity` | `check-parity` | Envelope corpus / parser oracle (`./pkg/backfill/...`, `./pkg/sessions/...`), no services |
| `make test-extproc` | `check-extproc` | `./extproc/...` and `./cli/extproc-replay-server/...`, no services |
| `make check-extproc-image` | `build-extproc-image` + `sync` | Image builds (CI gate); does not load |
| `make e2e-test` | `test-e-2-e` | Proxy / ingest / derive / span search with Postgres + Ollama |
| `make contracts` | `contracts` | Writes documented YAML to `./build/contracts` |
| `make generate` | *(local `sqlc`, not Dagger)* | Regenerates `pkg/storage/postgres/gensqlc` |

`dagger check` (`make check`) currently includes:

- `CheckGenerate` — regenerate sqlc @ v1.30.0 and `diff` against committed `gensqlc`
- `Test` — full suite + Postgres
- `CheckGoModTidy` — `go mod tidy` must be a no-op
- `CheckLint` — golangci-lint, no auto-fix
- `CheckExtproc` — extproc adapter tests
- `CheckParity` — envelope contract fixtures

<Note>
GitHub `ci.yaml` does **not** run `make test` / the full DB-backed suite on every PR. PR CI runs `make parity`, `make test-extproc`, `make check-extproc-image`, `make e2e-test`, `dagger call build`, and a `tapes version` smoke matrix. Run `make test` and `make check` locally before opening the PR.
</Note>

Tests use **Ginkgo / Gomega**. Do not introduce a second test framework.

## OpenAPI CONTRACT seals

There is no swaggo and no checked-in OpenAPI file. Routes register through the `oasfiber` wrapper (`api/openapi_routes.go`, `ingest/openapi.go`). Each running server compiles its contract and serves it at `GET /openapi`. Field prose comes from ordinary Go doc comments, not struct tags.

A deployed binary has no source tree, so `GET /openapi` has route/operation prose but not per-field prose. From a checkout:

```bash
tapes dev openapi                          # read API, YAML, prose from .
tapes dev openapi ingest                   # ingest write surface
tapes dev openapi --format json
tapes dev openapi --docs-root ''           # shapes only — what the seal covers
tapes dev openapi --out api-contract.yaml
```

<ParamField body="docs-root" type="string" default=".">
Module root to load doc comments from. Empty string skips prose (`gosource` is not walked). A bad path fails rather than silently emitting the stripped document.
</ParamField>

<ParamField body="format" type="string" default="yaml">
`yaml` or `json`.
</ParamField>

<ParamField body="out" type="string">
Write to a file (mode `0600`) instead of stdout. Stderr prints `openapi` version, path count, and fingerprint.
</ParamField>

Surfaces: `api` (default) and `ingest`. Nothing in this repo reads the compiled file. `make contracts` writes `build/contracts/tapes-api.yaml` and `tapes-ingest.yaml` **with** field prose (`--docs-root .`). Those bytes are for consumers; they are not checked in.

### What the seal is

`api/CONTRACT` and `ingest/CONTRACT` each hold one `sha256:…` fingerprint of the **prose-stripped** compiled JSON — the same document as `tapes dev openapi <surface> --docs-root ''` and the same document a released binary serves. Comment lines (`#`) and blanks are ignored; exactly one `sha256:` line is required.

`api/openapi_seal_test.go` and `ingest/openapi_seal_test.go` recompile with `nil` TypeDocs and compare. A move fails until you paste the printed fingerprint into the `CONTRACT` file **in the same change**.

| Change | Moves the seal? |
| --- | --- |
| Route, parameter, schema, status code, field name | Yes |
| `Summary` / `Description` declared inline on a registration | Yes |
| Go doc-comment edit (field prose) | No |
| Cassette mounted at runtime on `:8081` | No — the seal is the **core** document. The served `/openapi` can grow as cassettes come and go. |

Bump the seal in the same PR that moved the contract. A follow-up bump is a change nobody was asked to review.

To see what moved: `make contracts`, then diff the YAML. Do **not** paste a `make contracts` / default `tapes dev openapi` fingerprint into `CONTRACT` — that is the documented document, and it would make every comment edit a contract event.

`tapes dev check-openapi` is a different gate: it validates composite `GET /v1/sessions/{id}/traces` fixtures against the compiled `SessionTracesResponse` schema (types of fields present). Structural completeness is `tapes dev check-invariants`.

## sqlc generate

<ParamField body="sqlc.yaml" type="file" required>
PostgreSQL engine. Schema: `migrations/`. Queries: `pkg/storage/postgres/queries/{derive,derive_queue,raw_turns,sessions,skills,spans}.sql`. Output package: `gensqlc` → `pkg/storage/postgres/gensqlc` (`pgx/v5`).
</ParamField>

```bash
make generate          # sqlc generate  (Nix pins sqlc; CI CheckGenerate uses sqlc@v1.30.0)
```

`pkg/storage/postgres/gensqlc` is generated (`sqlc v1.30.0`). Do not edit it. After changing a query file or a migration that sqlc reads, run `make generate` and commit the diff. `CheckGenerate` copies `gensqlc`, regenerates, and `diff`s — drift fails `make check`.

## tapes vs tapesctl ownership

```mermaid
flowchart LR
  subgraph thisRepo["papercomputeco/tapes"]
    tapesBin["tapes: init, local, serve, status, auth, config, backfill, raw equivalence, dev, version"]
    services["proxy :8080 · read API :8081 · ingest :8082 · derive-worker · embed-worker"]
    extproc["tapes-extproc image — Envoy path, no DB dependency"]
    db["PostgreSQL — owned by this binary"]
    tapesBin --> services
    services --> db
  end
  subgraph clientRepo["papercomputeco/tapesctl"]
    ctl["tapesctl: start, capture, export, seed, sessions list, search, skill"]
  end
  ctl -->|"read :8081"| services
  ctl -->|"write :8082"| services
  extproc -->|"POST /v1/ingest"| services
```

| Stays in `tapes` | Moved to `tapesctl` — do not re-add |
| --- | --- |
| `init`, `local`, `serve`, `status`, `auth`, `config`, `backfill`, `raw equivalence`, `dev`, `version` | `tapes start` → `tapesctl start` |
| Span search **implementation** (`GET /v1/search/spans`, embed worker) | `tapes export` → `tapesctl export` |
| Database, migrations, derive, ingest | `tapes seed` → `tapesctl seed` |
| | `tapes sessions` → `tapesctl sessions list` |
| | `tapes search` → `tapesctl search` (client only) |
| | `tapes skill` → `tapesctl skill` |

`tapes chat` is gone. The deck TUI is gone; there is no CLI replacement. Search and skill **authoring** stay server-side for now.

`tapes-extproc` ships from this repo (`Dockerfile.extproc`, `cli/tapes-extproc`) as its own image, not as `tapes serve …`. It must not depend on the API, database, or derive worker being healthy. It shares `pkg/capture` reducers and ingest limits on purpose: two capture paths that reduce differently is the failure it exists to avoid.

## Documentation in the same PR

Every PR that changes user-visible behavior **must** update `docs/` in the same PR. The docs are flat Markdown, no build step: one page per file.

A new page needs all three:

- `title`
- `description` (one sentence)
- `sidebar.order`

Links are relative (`./cli.md`) so they resolve in the checkout and on the published site.

Do not write design documents or implementation plans to disk (`docs/plans/` or similar). Discuss plans in conversation only.

## PR title and CI labels

Search for an existing issue first. CI rejects any PR title that does not start with one of these. Merges are squashed: the **PR title is the commit message on `main`**.

| Prefix | Meaning | Semver |
| --- | --- | --- |
| `✨ feat:` / `:sparkles: feat:` | New feature | Minor (`0.1.0` → `0.2.0`) |
| `🔧 fix:` / `:wrench: fix:` | Bug fix | Patch |
| `🧹 chore:` / `:broom: chore:` | CI, tests, non-feature/non-bug | Patch |
| `♻️ refactor:` / `:recycle: refactor:` | Behaviour-preserving restructure | Patch |
| `🎨 design:` / `:art: design:` | Design / presentation | — |
| `📚 docs:` / `:books: docs:` | Documentation only | — |
| `✏️ RFD:` / `:pencil2: RFD:` | Request for discussion | — |

Optional scope in parentheses: `✨ feat(ingest): …`. Breaking change: `!` before the colon (`✨ feat!:`, `🔧 fix(api)!:`) — major bump.

The validator is `ghcontrib` from `papercomputeco/daggerverse` (`.github/workflows/pr.yaml`). If this table and `validPRTitleSpecs` ever disagree, the module wins.

A second job, `check-pull-request-linear-magic-word`, runs from the same module. The exact token is not defined in this repository.

## Constraints that break the product if ignored

- **`Bucket`** (`pkg/merkle.Bucket`) is the content-addressing unit. Changing it changes the in-memory merkle / dedup layer (`merkle.ProjectContent`) for every derive. The persisted `nodes` table is gone; merkle is not a user-facing browse surface.
- Prefer `func NewExampleStruct() *ExampleStruct`.
- Language: Go 1.26+. Module: `github.com/papercomputeco/tapes`.

:::files
tapes/
├── makefile                 # all supported build / test / format entry points
├── flake.nix / .envrc       # recommended toolchain + GOEXPERIMENT=jsonv2
├── sqlc.yaml                # query → gensqlc mapping
├── api/CONTRACT             # sha256 seal of prose-stripped read contract
├── ingest/CONTRACT          # sha256 seal of prose-stripped ingest contract
├── api/openapi_routes.go    # read-surface registrations
├── ingest/openapi.go        # write-surface registrations
├── pkg/storage/postgres/queries/   # sqlc inputs
├── pkg/storage/postgres/gensqlc/   # generated — do not hand-edit
├── migrations/              # sqlc schema + runtime migrations
├── cmd/tapes/dev/           # openapi, check-openapi, check-invariants, fixtures
├── .dagger/                 # Test, CheckGenerate, lint, parity, extproc, e2e
├── docs/                    # user-visible docs, same PR as the change
└── extproc/                 # Envoy adapter; separate image
:::

## `tapes dev` (checkout-only utilities)

| Command | Needs DB? | Role |
| --- | --- | --- |
| `tapes dev openapi [api\|ingest]` | No | Compile contract; fold field prose from the tree |
| `tapes dev trace-fixtures` | No | Replay corpus raw layers through the real deriver + API renderers |
| `tapes dev check-invariants` | No | Structural properties of composite session-traces JSON |
| `tapes dev check-openapi` | No | Types of present fields vs `SessionTracesResponse` |
| `tapes dev dump-corpus` | Yes | Inverse of fixture replay: export `raw_turns` to corpus files |
| `tapes dev rederive` | Yes | Same whole-project rebuild as `POST /v1/admin/derive/run`, as a direct DB call |
| `tapes dev embed-spans` | Yes | Backfill span embeddings |

## Common failures

| Symptom | Cause | Fix |
| --- | --- | --- |
| Build / merkle panic or jsonv2 errors | `GOEXPERIMENT` unset | Use Nix or `make build-local` |
| `make test` / `format` / `check` fail immediately | No Docker | Start Docker; Dagger needs it |
| DB tests fail on extensions / types | Stock Postgres | Use `make test`, not a hand-rolled container |
| `CheckGenerate` red | Hand-edited `gensqlc` or skipped generate | `make generate`, commit the diff |
| `openapi_seal_test` red | Route / inline prose / schema moved | Write the printed `sha256:` into `api/CONTRACT` or `ingest/CONTRACT` in **this** PR |
| Seal bumped but comments-only change | Pasted documented fingerprint | Recompile with `--docs-root ''` |
| PR title check red | Missing emoji conventional prefix | Match the table above |
| Next `tapes` invocation SIGKILL on macOS | Binary overwritten in place | `make install` |

## Next

<CardGroup>
<Card title="CLI reference" href="/cli-reference">
`tapes` commands and flags. `tapesctl` is the client, not this binary.
</Card>
<Card title="Read API" href="/read-api">
Compiled `GET /openapi` on `:8081` and CONTRACT seal rules.
</Card>
<Card title="Ingest API" href="/ingest-api">
Private write contract on `:8082`. A seal miss here breaks every capture adapter.
</Card>
<Card title="Read API vs ingest" href="/read-vs-ingest">
Two servers, two ports, two trust models.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
`GOEXPERIMENT=jsonv2` build failures and other operator pitfalls.
</Card>
<Card title="Gateway capture" href="/gateway-capture">
`tapes-extproc` lives in this repo; keep its reducers aligned with ingest.
</Card>
</CardGroup>

---
