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
Section titled “What you need”curl -sSfL https://download.tapes.dev/tapesctl/install | bashStart the stack
Section titled “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-worlddocker compose up --build -dBoth 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
Section titled “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
Section titled “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 cassettes # `hello-world` has appearedtapesctl cassettes hello-world --helpCommands: create-hello Write one row to the hello table get-hello Greet, and read back every stored rowThose names are the cassette’s own operationIds, kebab-cased, taken from the
document Tapes cached — this binary has never heard of hello-world:
tapesctl cassettes hello-world create-hellotapesctl cassettes hello-world get-helloEach method’s help names the route it calls, which is the one thing a generated command name cannot tell you.
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
Section titled “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 :8081curl -s "localhost:8081/v1/sessions?limit=5" | jq '.items[].display_title'Tear it down
Section titled “Tear it down”docker rm -f hw-ingest hw-derive # only if you ran the optional stepdocker compose down -vThe -v removes the Postgres volume, so the cassette role is provisioned again
on the next up.
Where to go from here
Section titled “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.