# Run a cassette locally

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

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