# Cassettes

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

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

## Source Files

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