Skip to content

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.

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

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.
Terminal window
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.

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

Terminal window
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:

Terminal window
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:

Terminal window
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.

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):

Terminal window
export TAPES_URL=http://localhost:8081
tapesctl cassettes # `hello-world` has appeared
tapesctl cassettes 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:

Terminal window
tapesctl cassettes hello-world create-hello
tapesctl cassettes hello-world get-hello

Each 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.

Terminal window
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:

Terminal window
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:

Terminal window
tapesctl sessions list # TAPES_URL still points at :8081
curl -s "localhost:8081/v1/sessions?limit=5" | jq '.items[].display_title'
Terminal window
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.

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.