Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Running a cassette locally

This walkthrough proves the cassette path end to end on one machine, with nothing but this repository, Docker, and tapesctl: a standalone Tapes admits the bundled hello-world cassette, republishes its API, and tapesctl turns the discovered surface into commands. No orchestrator or platform deployment is involved — the registration mechanism is ordinary operator configuration (--cassettes), so everything here works against any Tapes you run yourself.

For the cassette contract itself — the manifest schema, admission rules, and deployment responsibilities — see Cassettes. This page is the follow-along companion to it.

What you need

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

Start the stack

The runnable example at pkg/cassette/examples/hello-world ships its own deployment, because Tapes does not start cassettes — something else always has to. Its compose.yaml runs three services:

  • postgres, provisioning the cassette’s role at first initialization;
  • hello-world, the cassette, published on 127.0.0.1:9999;
  • tapes, the API server on 127.0.0.1:8081, started with --cassettes http://hello-world:9999/openapi.
cd pkg/cassette/examples/hello-world
docker compose up --build -d

Both images build from source for your machine’s native architecture, which is what you want. Do not force --platform linux/amd64 on an Apple Silicon host: the Go toolchain is unreliable under QEMU emulation and the build can crash. Cross-building, when actually needed, belongs in a builder that compiles natively and targets GOOS/GOARCH — not in this walkthrough.

Tapes retries the cassette source through startup and on every refresh, so the ordering of the three containers does not matter; give it a few seconds after up returns.

Verify admission

Discovery lists the admitted cassette, its manifest digest, and any rejected sources:

curl -s localhost:8081/v1/cassettes | jq
{
  "contract_version": "v1",
  "cassettes": [
    {
      "name": "hello-world",
      "version": "0.0.1",
      "route_prefix": "/v1/cassettes/hello-world",
      "openapi_path": "/v1/cassettes/hello-world/openapi.json",
      "openapi_status": "fresh",
      "manifest_digest": "sha256:8171d476..."
    }
  ],
  "problems": []
}

The cached per-cassette document and the aggregate document both republish the cassette’s paths under the Tapes namespace:

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"]

And the proxied API round-trips — the cassette serves /api/hello-world/hello on its own listener, but clients only ever see the rewritten public path:

curl -s -X POST localhost:8081/v1/cassettes/hello-world/hello
# {"id":1,"hello":"hello","world":"world","created_at":"..."}

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

"store": "postgres" confirms the deployment-supplied credential worked; run the compose file with an empty HELLO_WORLD_DATABASE_URL to watch the same cassette fall back to memory and say so.

Drive it with tapesctl

tapesctl reads the same discovery surface and generates a subcommand per cassette, with a method per OpenAPI operation. Because the nouns have to exist before the command line is parsed, point discovery at the server with the TAPES_URL environment variable (the --tapes-url flag also works, on any subcommand):

export TAPES_URL=http://localhost:8081

tapesctl --help          # a `hello-world` command has appeared
tapesctl hello-world --help
Commands:
  create-hello  Write one row to the hello table
  get-hello     Greet, and read back every stored row

Those names are the cassette’s own operationIds, kebab-cased, taken from the document Tapes cached — this binary has never heard of hello-world:

tapesctl hello-world create-hello
tapesctl hello-world get-hello

The discovered surface is cached per server and revalidated with ETag, so --help stays instant and works offline once seen.

Optional: capture a session beside the cassette

The example composes only the API server, because none of the cassette surface needs anything else. But the stack it runs is a complete standalone Tapes: add the ingest server and the derive worker — from the image the compose build already produced — and it captures real sessions too.

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'

Run a harness under capture, pointed at the ingest server:

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

After the derive worker’s debounce (about twenty seconds), the session, its trace, and its spans are readable from the same API that serves the cassette:

tapesctl sessions list          # TAPES_URL still points at :8081
curl -s "localhost:8081/v1/sessions?limit=5" | jq '.items[].display_title'

Tear it down

docker rm -f hw-ingest hw-derive   # only if you ran the optional step
docker compose down -v

The -v removes the Postgres volume, so the cassette role is provisioned again on the next up.

Where to go from here

Everything the example does — the manifest, the OpenAPI extension, the provisioning, the admission rules it satisfies — is specified in Cassettes. Start from the example’s source in pkg/cassette/examples/hello-world when building your own.