The command-line client for Tapes, written in Rust.
Tapes records what coding agents actually did: every LLM call an agent made, the
tools it ran, and the shape of the work — as sessions, traces, and spans you can
read, search, and export. tapesctl is the client. It launches a coding-agent
harness under a just-in-time capture proxy, ships the captured turns to a tapes
server, and gives you a command line over the data model that comes back.
You bring your own tapes server; tapesctl never guesses one. Everything below
that reads or writes data takes a --tapes-url, and
Naming your server is the one-time way to stop typing it.
curl -sSfL https://download.tapes.dev/tapesctl/install | bashEvery published artifact carries a .sha256 sidecar. Where sha256sum or
shasum is available, the installer verifies the download against that sidecar
before installing, and a missing sidecar is a hard failure rather than a skipped
check; with neither tool present it warns and installs unverified. Binaries land
in /usr/local/bin (via sudo only if that directory is not writable). Set
TAPESCTL_VERSION to install a specific release or nightly, and
TAPESCTL_INSTALL_DIR to install somewhere else.
Confirm it landed:
tapesctl versionSupported platforms are Linux and macOS, on x86-64 and arm64.
start launches a harness the way you normally would, with a capture proxy in
front of it. The harness behaves exactly as it would unproxied — traffic is
forwarded to its own provider API by default — and the proxy dies with it.
tapesctl start claude --tapes-url http://localhost:8082The supported harnesses are claude, codex, and pi. Anything after the
harness name is passed through verbatim, so your usual flags still work:
tapesctl start claude --tapes-url http://localhost:8082 -- --model opusA capture records two lanes, and both matter:
- the wire lane — every LLM call, forwarded byte-for-byte through a loopback proxy;
- the transcript lane — the harness's own on-disk transcripts, tailed live and pushed as they settle.
Only the transcript lane carries a session's causal skeleton: which Task
tool_use forked which subagent. A capture without it records every call a
subagent made but renders that work as flat dispatch text instead of nested
rows. Pass --no-transcripts only when another capture client is already
tailing the same tree.
While a harness is running it owns the terminal, so start writes its
diagnostics to a file instead of the screen — a stray log line lands in the
middle of a TUI frame. The path is printed before the harness launches and
again when it exits:
~/.tapes/logs/start-<timestamp>-<pid>.logRUST_LOG sets the level as usual. Pass -v (before the subcommand) to stream
to stderr instead of a file, accepting what that does to the display:
tapesctl -v start claude --tapes-url http://localhost:8082Every other command logs to stderr as before.
tapesctl sync # backstop: sweep transcripts no live tailer sawsync is safe to run repeatedly — the ingest endpoint keys rows on a content
hash, so re-offering an unchanged transcript is a cheap deduped. It sweeps
~/.claude/projects by default (--projects-root to point elsewhere), and
--since-days bounds how far back it looks.
pi needs its capture plugin installed once before start can capture it:
tapesctl plugin install pi
tapesctl start pi --tapes-url http://localhost:8082 -- --provider anthropic --model <model-id>Pass both --provider and --model, or neither. Those are pi's own
flags, not tapesctl's, which is why they come after --. pi only honours
them as a pair; given just one it ignores it and falls back to your saved
default or the first provider it finds a key for — which may be a provider this
capture does not cover, so the session runs and records nothing. pi warns
inside the harness when the selected model's provider is not covered.
A plain tapesctl start pi routes each of pi's Anthropic, OpenAI, and OpenAI
Codex providers to its own upstream, so all three are captured. --schema
(anthropic, the default, or openai) picks which schema the capture fronts;
an explicit --upstream sends everything to one place instead. A harness that
speaks exactly one schema takes it from the harness, and passing --schema
there is an error rather than a silent no-op.
Every command that talks to a tapes deployment needs to know where it is. There are three ways to say so, and they are consulted in this order:
tapesctl --tapes-url http://localhost:8081 sessions list # 1. the flag
export TAPES_URL=http://localhost:8081 # 2. the environment
tapesctl config set tapes-url http://localhost:8081 # 3. once, for good--tapes-url is global: give it before the subcommand, as above, or after it,
where it has always worked. The third form writes ~/.tapes/config.toml and is
the one worth doing — a configured server is what makes tapesctl cassettes
list what your deployment serves, in every new shell, without an export.
tapesctl config get # every key that is set
tapesctl config get tapes-url # one of them, bare, for scripts
tapesctl config path # where the file is, whether or not it existsconfig set edits the file in place rather than rewriting it, so your comments,
your ordering, and any keys this build does not know about — a key a newer
tapesctl wrote, say — all survive. The server must be an http or https URL;
anything else is refused when you set it rather than on every command afterwards.
With none of the three, commands that need a server refuse to run and say so.
They do not fall back to a guessed localhost port: a capture pointed at
whatever happened to be listening is worse than one that did not start.
tapesctl sessions list --limit 20
tapesctl sessions get <session-id>
tapesctl sessions traces <session-id> # what the console renders
tapesctl sessions raw-turns <session-id> # the wire turns behind the derivation
tapesctl traces list <session-id>
tapesctl traces get <trace-id>
tapesctl spans list <trace-id>
tapesctl spans get <trace-id> <span-id>Each prints the server's JSON verbatim, so it composes with jq. sessions list pages with --limit/--cursor and narrows with --sort,
--direction, --since, --until, and --auth-subject; a cursor is only
valid with the --sort and --direction it was minted under. sessions traces and spans list take --payload preview to truncate payload strings
server-side.
tapesctl export <session-id> -o bundle.jsonl # --detail spans (default) or traces
tapesctl seed # demo data for a fresh serverAn app you launch from the dock starts itself, so there is no process for
start to own. Install a plugin once, then run a proxy for as long as you want
the app captured.
tapesctl plugin install codex-app --tapes-url http://localhost:8081
tapesctl capture codex-app --tapes-url http://localhost:8082plugin install packages a hook plugin under ~/.tapes/codex-app/, points
~/.codex/config.toml at a loopback port recorded at install time, and
registers the plugin with the codex CLI. Because that endpoint outlives any
one capture, the port cannot be ephemeral the way start's is — pass --port
to pin it, or re-run with an explicit one to move off a port something else has
taken. --dry-run reports exactly what would be written, and where, without
writing anything. --codex-auth selects which credential is presented upstream:
chatgpt (the default, what the app uses after a plan login) or api-key.
Two steps are yours, and the command prints them:
- Restart the Codex app, then enable the plugin in the app's Installed list.
- In the
codexCLI, run/hooksand trust the plugin's hooks. The app has no/hookscommand and its Hooks settings page does not list plugin hooks, but trust is shared state, so trusting once in the CLI covers the app too. Trust binds to the exact hook-definition hash, so a reinstall requires trusting again.
tapesctl plugin uninstall codex-app removes the configuration and state it
wrote, but leaves the plugin registered with Codex; it prints a
codex plugin remove ... command to run for that last step. It also takes
--dry-run.
Harnesses captured by redirection alone need no plugin and say so:
$ tapesctl plugin install claude
tapesctl: claude needs no capture plugin — its traffic is captured by
redirecting it, which `tapesctl start claude` does.
plugin install knows claude, codex, codex-app, opencode, and pi.
tapesctl search "how to configure logging"
tapesctl search "error handling patterns" --top 10
tapesctl search "gum glow charm" --quiet # session ids, one per lineHits are individual main-conversation LLM spans with their trace and turn
context — "find the turn where X happened". This needs a server with span
embeddings written; a deployment without them answers 503 rather than an
empty result set.
--quiet prints bare session ids in score order, which is what skill generate
takes as arguments:
tapesctl skill generate $(tapesctl search "charm CLI" --quiet --top 1) --name charm-patternsA skill is a markdown file with frontmatter under ~/.tapes/skills/. Generate
one from captured sessions, list what you have, and install it where an agent
will look:
tapesctl skill generate <session-id> --name debug-react-hooks
tapesctl skill generate --search "react hooks" --search-top 3 --name react-debug
tapesctl skill generate <session-id> --name morning-work --since 2026-02-17
tapesctl skill list --type workflow
tapesctl skill sync debug-react-hooks --claude # copy it into placegenerate talks to two servers, and they are not the same one: --tapes-url
for the session transcript, and an LLM provider for the extraction. The provider
is --provider (openai, anthropic, or ollama), keyed from --api-key or
the provider's own environment variable — prefer the variable, since an argument
is visible in the process list. --model overrides that provider's default, and
--preview renders the skill without writing it.
Skill files are written 0600, and a skills path that resolves outside the
directory you selected is refused rather than followed.
A tapes deployment can serve cassettes — independently built API extensions
mounted under /v1/cassettes/<name>. tapesctl discovers whichever ones your
server serves and mounts them under tapesctl cassettes, so the noun and its
--help are the cassette listing:
tapesctl cassettes # what this server serves
tapesctl cassettes hello-world --help # that cassette's methods
tapesctl cassettes hello-world get-hello
tapesctl cassettes hello-world create-hello --body '{"hello":"hi"}'
tapesctl cassettes hello-world create-hello --body @row.jsonMethod names are each operation's operationId, kebab-cased. Path parameters
become positional arguments and query parameters become flags, both taken from
the cassette's own OpenAPI document — so a cassette this binary has never heard
of still gets a correct, typed-ish command line.
Discovery is a runtime step, not a build-time one: which cassettes exist is
deployment configuration, so a compiled-in list would be one deployment's
cassettes frozen into everyone's binary. The discovered surface is cached per
server and revalidated on a timer (ETag/If-None-Match), so --help stays
instant and keeps working offline. Override the cache location with
TAPESCTL_CACHE_DIR.
Because the listing comes from a server, tapesctl cassettes on a machine that
names none lists nothing at all — which is the strongest reason to run
tapesctl config set tapes-url once. Everything above this section is
unaffected. Deploying and configuring cassettes is an operator task and is not
part of this surface.
Cassettes used to mount as top-level nouns (tapesctl hello-world get-hello).
That spelling shipped one release as a hidden alias and has been removed: it
now fails like any other unknown command. Write tapesctl cassettes <name> <method>. Retiring it is also what makes every non-cassette command start
without touching the discovery cache or the network at all.
The Nix flake dev shell pins the Rust toolchain (via rust-toolchain.toml):
nix develop
make build
make run ARGS=versionRun make help for all targets. Before opening a pull request:
make lint # cargo fmt --check + clippy -D warnings
make testSee AGENTS.md for repository layout, the conventions the workspace enforces, and the traps worth knowing before your first change.
CI runs through Dagger (.dagger/), so it reproduces locally:
make ci # dagger call lint + test (the PR gates)
make dist # cross-compile all four release targets into ./buildRelease binaries are cross-compiled from Linux with cargo-zigbuild — a pure
CLI with no Apple frameworks needs no macOS SDK. Targets: linux/{amd64,arm64}
(static musl) and darwin/{amd64,arm64} (Mach-O). Tagged releases and nightlies
publish to download.tapes.dev via the release / nightly Dagger functions.
A release publishes install.sh in the same pipeline call as the binaries,
after them. The object-store syncs are still separate — a late failure can
leave new binaries public with a stale installer — but that failure fails the
release, so a cut never reports success while the served installer is stale.
crates/tapesctl— the CLI binary.start/— the just-in-time capture proxy (the wire lane).transcript/— the transcript lane: live tailer andsyncsweep.codex_app/— the plugin and proxy for a harness that launches itself.api/— the<resource> <method>read client.cassette/— the generatedcassettes <name> <method>surface: discovery, the spec reducer, the cache, and clap synthesis.config.rs—~/.tapes/config.toml: the answers you give once.machine.rs— the crate's one ambient read of the environment.ports/— search, skills, and seed.
Shared client-side code — launch recipes, session attribution, transcript
discovery, the capture envelope, and the tapes read client itself (its
vendored contract, its response models, and the transport they travel over) —
lives in its own repository,
tapes-crates, and is
consumed here as a pinned dependency. What stays in api/ is what is a
command line's rather than a client's: which operations this CLI exposes, and
how their answers are printed.
Contributions are welcome — see AGENTS.md for how to build, test, and shape a pull request.
Dual-licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.