Every tapesctl command with its flags, environment equivalents, exit codes, and error families.
Fourteen top-level commands, plus whatever cassette surface your deployment
serves. This page is the whole reference; Capture explains the
concepts behind start, capture, and sync, and
Cassettes covers the discovered surface.
Read commands use --api-url; capture commands use --ingest-url. See
The two ports.
Global flags
Section titled “Global flags”Both are declared globally, so they may be given before or after the subcommand and reach every leaf.
| flag | type | default | notes |
|---|---|---|---|
-v, --verbose |
count | 0 |
-v adds sync file outcomes and enables debug; -vv enables trace. RUST_LOG overrides the log level, not sync detail |
--api-url <URL> |
string | http://localhost:8081 |
falls back to TAPES_API_URL, then config.toml |
-h, --help |
flag | — | |
-V, --version |
flag | — | prints one line; see version before trusting it |
Both are also read straight off the argument list before parsing, and both stop
at a bare --. A harness’s own -v or --api-url after the separator
cannot steer tapesctl.
Leaf position beats global position. Given both,
tapesctl --api-url A sessions list --api-url B uses B.
--api-url appears in the help of commands that never make an HTTP
call — config set, config get, config path,
version, upgrade, uninstall, and plugin uninstall — because the global
flag propagates into
every leaf’s help. It is inert there. Its presence in config’s help is
actively misleading, since the point of config set api-url is that you do
not have a server configured yet.
Exit codes
Section titled “Exit codes”Three values, and only three.
| code | meaning |
|---|---|
0 |
success |
1 |
a runtime error — a tapesctl: line on stderr, plus a caused by: line per underlying cause and, when there is one, a hint: line |
2 |
an argument-parsing error, or help printed because a subcommand was missing |
A bare tapesctl prints help and exits 2. So does tapesctl sessions,
tapesctl cassettes, or any other noun given without a verb. Scripts under
set -e should call tapesctl version to check for the binary, not a bare
invocation.
start does not propagate its harness’s exit status: a non-zero child is
warned about and start still exits 0.
Environment variables
Section titled “Environment variables”Every variable tapesctl reads.
| variable | read by |
|---|---|
TAPES_API_URL |
every read command, cassette discovery, and generated methods |
TAPES_INGEST_URL |
start, capture, sync |
TAPES_UPSTREAM |
start, capture |
TAPES_WEB_URL |
start, capture |
TAPES_ORG_ID |
start, capture |
TAPES_AUTH_SUBJECT |
start, capture, sync |
RUST_LOG |
logging, all commands |
TAPESCTL_CACHE_DIR |
the cassette surface cache |
CODEX_HOME |
plugin install/uninstall codex-app, capture codex-app, start codex |
USER, then USERNAME |
the default --auth-subject (local:<user>, else local:unknown) |
OPENAI_API_KEY |
start codex upstream selection |
There is no telemetry variable, because there is no telemetry.
Launch a harness under a capture proxy and ship its turns to the ingest server.
tapesctl start claude --ingest-url http://localhost:8082tapesctl start claude --ingest-url http://localhost:8082 -- --model opusAnything after -- is passed to the harness verbatim.
| flag | default | env |
|---|---|---|
<HARNESS> |
required — claude, codex, pi, or hermes |
— |
[HARNESS_ARGS]... |
— | — |
--ingest-url <URL> |
http://localhost:8082 |
TAPES_INGEST_URL |
--upstream <URL> |
the harness’s own provider API | TAPES_UPSTREAM |
--schema <SCHEMA> |
the harness’s own | — |
--model <MODEL> |
none — hermes asks the endpoint; pi declares nothing | — |
--web-url <URL> |
none | TAPES_WEB_URL |
--org-id <UUID> |
"" — the server’s local sentinel org |
TAPES_ORG_ID |
--auth-subject <S> |
local:<username> |
TAPES_AUTH_SUBJECT |
--no-transcripts |
off | — |
--no-start-hint |
off | TAPESCTL_NO_START_HINT |
--schema is anthropic or openai, and only applies to a harness that
speaks more than one — pi (default anthropic, or openai when --model
declares a model, which --schema anthropic then contradicts and is refused)
and hermes (default openai, meaning chat completions). On claude or codex it is an error, not
a no-op.
--model applies to hermes, whose provider config start writes, and to
pi, where it declares a model the upstream serves that pi does not ship (see
capture); pi’s own
models are still chosen with pi’s flags after --. Every other harness takes
its model from its own flag after --, and passing --model to one is an
error.
--web-url is used only to build the printed console link.
Unless --no-start-hint is given, start claude passes Claude Code one
--append-system-prompt line, ahead of your own arguments:
Recall across every captured session: `tapesctl search <q> [--subject ID|me] [--since 7d]` finds prior sessions by anyone; `tapesctl sessions transcript <id>` reads one.It appends to Claude’s instructions rather than replacing them. codex and
pi get no hint: their launches have no additive channel for one.
Endpoints: POST {ingest-url}/v1/ingest for the wire lane,
POST {ingest-url}/v1/ingest/transcript for the transcript lane. Neither sends
an authentication header. The proxy listens on 127.0.0.1:0 — an ephemeral
port, per launch.
A base path in --ingest-url is discarded. --ingest-url http://host:8090/base/
posts to http://host:8090/v1/ingest, not /base/v1/ingest. Upstream
forwarding is the opposite and concatenates, so an upstream route prefix
survives.
What start prints
Section titled “What start prints”Before launch, and only when diagnostics went to a file:
capturing · logs at ~/.tapes/logs/start-20260813-180411-54233.logBetween spawn and harness exit, nothing at all. At exit, exactly one of:
no turns were captured✓ captured session <id> <console url>✓ captured session <id> pass --web-url for a console linkcaptured <n> turn(s) (<u> unattributed — filed as unknown)then, on stderr when any turn was unattributed:
tapesctl: warning: <u> captured turn(s) could not be attributed to this session and were filed as unknownthen, on stderr only if the shutdown drain gave up:
tapesctl: warning: <n> turn(s) still being captured at exit; the counts above may be shortand finally, on stdout, an indented dim logs at <path>.
The printed session id is the harness’s, not the one read commands take — see Session ids.
start errors
Section titled “start errors”All exit 1.
| message | when |
|---|---|
unsupported harness "X" (supported: claude, codex, pi, hermes) |
unknown name, or opencode |
--schema does not apply to claude, which speaks anthropic only (it is for a harness that redirects several providers to one endpoint, such as pi) |
--schema on claude or codex |
invalid --schema "X" (valid values: anthropic, openai) |
bad --schema value |
--model does not apply to claude; pass the harness's own model flag after – instead (…) |
--model on anything but hermes or pi |
--model "anthropic/…" does not name a model to declare for pi; … |
pi --model naming one of pi’s own providers’ models |
--schema anthropic conflicts with --model for pi: … |
pi --model with --schema anthropic |
--upstream <url> cannot carry a captured pi --model session: … |
pi --model with an --upstream whose path is neither empty nor ends in /v1 |
pi cannot be captured until its capture plugin is installed: no plugin at <path>. Run tapesctl plugin install pi first. |
the pi extension is absent — checked before anything binds or spawns |
could not bind the capture proxy / could not start <harness> |
loopback bind or spawn failure |
Capture failures never appear here. An oversize body, an ingest rejection, a non-JSON request body — each is logged and the turn is skipped, because a telemetry failure must never take the harness down.
Delivery failures are summarised at exit, though, because the log is not where
anyone looks first: when captured turns could not reach ingest, start prints
tapesctl: error: could not reach the tapes ingest server at <url>; … to
stderr once, after the summary line, and a count of turns ingest rejected when
it rejected any.
capture
Section titled “capture”Bind the address a self-launching harness was installed against, and capture
whichever sessions run in that window. Today the only harness is codex-app.
tapesctl capture codex-app --ingest-url http://localhost:8082A deliberate subset of start’s flags: there is no --schema, no
--no-transcripts, and no trailing-argument passthrough — tapesctl capture codex-app -- -p hi is a parse error.
| flag | default | env |
|---|---|---|
<HARNESS> |
required | — |
--ingest-url <URL> |
http://localhost:8082 |
TAPES_INGEST_URL |
--upstream <URL> |
the backend honouring the configured credential | TAPES_UPSTREAM |
--web-url <URL> |
none | TAPES_WEB_URL |
--org-id <UUID> |
"" |
TAPES_ORG_ID |
--auth-subject <S> |
local:<username> |
TAPES_AUTH_SUBJECT |
Prints capturing <harness> on <addr> — start a session in the app; Ctrl-C to stop, then one line per session, then stopped after <n> session(s).
There is no exit summary — no turn counts and no unattributed warning,
because capture’s tally is never drained.
Errors (exit 1) include unknown harness "X" (known: claude, codex, codex-app, opencode, pi), a not-a-hook-harness refusal, and five handoff
failures that each end by naming tapesctl plugin install codex-app. A
mismatch between the handoff address and the app’s own configuration is refused
rather than warned about.
Sweep completed Claude transcripts on disk into the ingest server.
tapesctl sync --ingest-url http://localhost:8082tapesctl sync --ingest-url http://localhost:8082 --since-days 0| flag | default | env |
|---|---|---|
--ingest-url <URL> |
http://localhost:8082 |
TAPES_INGEST_URL |
--projects-root <PATH> |
~/.claude/projects |
— |
--harness-id <ID> |
claude |
— |
--auth-subject <S> |
local:<username> |
TAPES_AUTH_SUBJECT |
--since-days <N> |
7 — see below | — |
--since-days defaults to 7, and --help does not say so. The declaration
carries no default and the parsed value is genuinely absent; an absent value is
mapped to seven days downstream. --since-days 0 sweeps everything. The window
is a cost bound, never a correctness one.
sync reads Claude-shaped trees only. The sweep expects the
~/.claude/projects layout (<project>/<session>.jsonl) and the server derives
Claude Code records. --harness-id changes the label the sessions are filed
under, nothing else: pointing --projects-root at a Codex tree still finds
zero sessions. To import another harness, first rewrite its history into that
layout and shape, then sync it with the matching --harness-id.
Normal mode prints three lines:
Swept 2 sessions (2 files) ✓ 1 new · 1 unchanged 2 sessions queued for projectionThe mark is ✓ when nothing failed and ✗ when something did, with a
<n> failed count added. The second line distinguishes accepted new content
versions from files the server already held (unchanged). If a successful
response omits dedup status, an <n> outcome unknown count is added rather
than treating that file as new. The third line does not mean projection
completed: ingest queued asynchronous projection for each unique session with
at least one successful response, and sync does not poll the read API. Reads
may lag. Deduplicated uploads are successes and requeue projection
server-side; the server remains the only source of truth, with no client
upload ledger.
Global -v adds one line per offered file above the summary, outcome first so
a column of them scans:
new sid-1 /home/me/.claude/projects/-work/sid-1.jsonl 42 records unchanged sid-2 /home/me/.claude/projects/-work/sid-2.jsonl 18 recordsThe outcomes are new, unchanged, failed, and unknown. A failure has no
server-reported record count and prints ? records. Successful
acknowledgement fields are independent: if records is absent only the count
is ?; if deduped is absent only the outcome is unknown. Sync preserves
whichever field the server did report rather than guessing or discarding
both. Normal mode omits per-file detail. -v also turns tapesctl’s own
INFO narration on; without it only warnings reach stderr.
Any failure then exits 1 with <n> of <m> transcript(s) could not be delivered. Both aggregate lines print before the final error, and everything
that landed is durable. The queued count excludes a session when all of its
files failed, but includes it once when any file received a successful response.
A historical sync can create partial, browsable calls directly from transcript content even when the wire proxy captured none. This is lower fidelity than wire capture: exact provider requests and response bytes are unavailable, and some harness-side calls or context may not appear in the transcript. Once a usable wire call arrives, the whole session switches to wire projection; wire-derived calls replace the transcript-derived fallback rather than combining with it, while transcript evidence continues to supply causal structure.
sessions
Section titled “sessions”Read commands. list renders a borderless table and get a record view, both
laid out for the terminal width; --json on either restores the raw document.
traces and raw-turns are the console’s own documents and print JSON.
Responses are never re-modelled on the way through, so fields the server grows
reach you without a client upgrade.
TITLE STATUS TURNS COST LAST ACTIVE IDAdd a table view completed 12 $0.04 5m ago 01a0d365-2f42-77a1-8473-bd2e295244a4untitled (f47ac10b) unknown — — 2h ago 01a0d365-2895-77f7-9ac2-dad41f0a1577
2 sessions · more with --cursor eyJzb3J0IjoibGFzdF9… (full cursor: --json)The table shows the columns that fit: HARNESS and MODEL appear from 110
columns; on a narrow one COST and then TURNS give way first. ID is the
full id and is never dropped or shortened: ids are time-ordered, so any prefix
collides across a day’s sessions. The time column follows --sort:
LAST ACTIVE by default, STARTED under --sort started_at; any other sort
is named in the footer (sorted by total_cost_usd). Piped, the cursor is
printed whole, and absent values are - so awk
still sees a field. Colour is applied only on a terminal and never when
NO_COLOR is set.
| leaf | route | flags |
|---|---|---|
list |
GET /v1/sessions |
--limit, --cursor, --sort, --direction, --since, --until, --harness-id, --harness-session-id, --auth-subject, --subject, --filter, --json |
get <ID> |
GET /v1/sessions/{id} |
--json |
traces <ID> |
GET /v1/sessions/{id}/traces |
--payload, --limit, --cursor |
raw-turns <ID> |
GET /v1/sessions/{id}/raw_turns |
--limit, --cursor |
transcript <ID> |
GET /v1/sessions/{id}/transcript, every page |
--role, --grep, --subagents, --json |
tapesctl sessions list --limit 20 --api-url http://localhost:8081tapesctl sessions get 01JDQ8F3K2M4N6P8R0T2V4X6Z8 --api-url http://localhost:8081sessions list flags are all optional and all omitted from the query when
unset, so the server’s own defaults apply. The one coupling: the server accepts
the harness filter only whole, so --harness-id and --harness-session-id
come as a pair — a lone half fails at parse with the missing half named.
| flag | behaviour |
|---|---|
--limit <N> |
the server defaults to 50 and clamps at 200 |
--cursor <C> |
only valid with the --sort and --direction it was minted under; changing either is a 400 |
--sort <COL> |
e.g. last_active, started_at, total_cost_usd |
--direction <D> |
asc or desc |
--since, --until |
RFC 3339, or a duration ago (30m, 24h, 7d, 2w) converted to RFC 3339 before sending |
--harness-id <H> |
the harness the session ran under (e.g. claude) — the other half of the pair |
--harness-session-id <ID> |
exact match on the harness session id — the id start prints; pairs with --harness-id; see Session ids |
--auth-subject <S> |
exact match |
--subject <S> |
an auth subject id, resolved client-side and sent as auth_subject: me is this machine’s own subject (TAPES_AUTH_SUBJECT, else local:<username>); a bare value with no : such as alice means alice or local:alice, and GET /v1/subjects picks the one the server has (both existing is an error naming them). Conflicts with --auth-subject |
--filter <KEY=VALUE> |
repeatable; each pair is passed through as ?key=value, verbatim and in order. The key names a filter param a cassette on your deployment claims at runtime — nothing is validated or filtered client-side, and a key nothing claims is ignored by the server |
--json |
print {"items": [...], "next_cursor"?: "..."} instead of the table; the cursor appears only when there is another page, and any other key the server sends is kept |
--payload takes full (the default) or preview, case-insensitively. An
unknown value fails before any request is made:
tapesctl: invalid --payload "bogus" (valid values: full, preview)sessions traces is what the console renders; sessions raw-turns is the wire
turns behind that derivation. Both are paged by the server (50 traces and 200
raw turns per page by default) and print one page: a page that is not the last
ends with next_cursor, which --cursor passes back. traces get pages its
spans the same way (200 per page) and, in the record view, names the command
that fetches the next page.
sessions transcript reads a session as a conversation, walking every page.
Each row prints as [<started_at> <role>] <text>, then → name(input) per
tool call and ← first line per tool result ( ← ERROR … when it
failed). --role is user, assistant, or all (the default); --grep
keeps rows whose text contains the pattern, case-insensitively (the server’s
q); --subagents includes subagent threads. --json prints
{"items": [row, ...], "session_id": "..."}, where a row carries seq,
trace_id, span_id, thread_id, started_at, role, text, and, when
present, tool_calls (id, name, input) and tool_results
(tool_use_id, text, is_error).
The read API carries no authentication, and redirects are refused rather
than followed. A base path in --api-url is discarded here too.
subjects
Section titled “subjects”| leaf | route | flags |
|---|---|---|
list |
GET /v1/subjects |
--q <TEXT>, --limit <N>, --json |
One row per auth subject id the server has sessions for, most recently seen
first: subject, session count, last seen. tapes stores only these opaque ids;
--q is a substring of the id. Each id works as --subject on search and
sessions list. --json prints
{"items": [{"subject", "session_count", "last_seen_at"}, ...]}.
traces
Section titled “traces”| leaf | route | flags |
|---|---|---|
list <SESSION_ID> |
GET /v1/traces?session_id= |
--json |
get <TRACE_ID> |
GET /v1/traces/{trace_id} |
--payload, --limit, --cursor, --json |
| leaf | route | flags |
|---|---|---|
list <TRACE_ID> |
GET /v1/traces/{trace_id}, projected to its spans array |
--payload, --json |
get <TRACE_ID> <SPAN_ID> |
GET /v1/traces/{trace_id}/spans/{span_id} |
--json |
spans list is a projection, not a route. The API has no standalone span
collection — spans exist only inside a trace — so the command fetches the trace
and prints its spans. The server pages a trace’s spans (200 per page), and
spans list walks every page before printing, so the list is the whole trace. A trace with no spans key prints No spans. ({"items": []}
under --json) rather than failing. --json prints {"items": [span, ...]}.
traces list is one row per turn: status, span count, tokens in → out, cost,
how long it took, when it started, the prompt, and the trace id last. spans list is one row per span in sequence order, with child spans indented under
their parent. traces get and spans get are records; a span’s input and
output documents follow its fields when it has any.
spans get takes two positionals. The trace id is not optional.
search
Section titled “search”Semantic search over every author’s captured sessions on the server. Hits are individual main-conversation LLM spans with their trace, turn, and session context.
tapesctl search "how to configure logging"tapesctl search "billing rollout" --subject alice --since 30dtapesctl search "flaky test" --livetapesctl search "auth redirect" --json | jq '.items[]'| flag | default | notes |
|---|---|---|
<QUERY> |
required | |
-k, --top-k <N> |
5 |
the server has no ceiling on this; --top still parses |
--subject <S> |
none | an auth subject id; me is this machine’s own subject, and a bare alice is sent as both auth_subject=alice and auth_subject=local:alice (OR’d) |
--cwd <PATH> |
none | sessions whose working directory starts with this |
--harness <H> |
none | harness id, exact |
--since <T>, --until <T> |
none | RFC 3339, or a duration ago (30m, 24h, 7d, 2w) |
--live |
off | only sessions with no recorded end, seen in the last five minutes |
-q, --quiet |
off | one bare session id per line, deduplicated in score order |
--json |
off | {"items": [hit, ...], "query": "...", "top_k": N}; conflicts with --quiet |
-v (global) |
off | print each hit’s whole prompt instead of one elided line |
Route: GET /v1/cassettes/search/spans — the search cassette’s span search.
query and top_k are always sent; every filter is sent only when set, and
me and relative times are resolved before sending.
Each hit under --json carries score, session_id, trace_id, span_id,
started_at, user_prompt, snippet, model, auth_subject, cwd,
harness_id, display_title, last_seen_at, and live, plus any field the
cassette adds.
--quiet is a pipe format, not a verbosity setting: one bare session id
per line, deduplicated in score order, ready for command substitution into
anything that takes session ids — for example the skills cassette’s generate
operation (tapesctl skills --help shows its current shape).
Non-quiet output is a ranked list: the query and a hit count on the first
line, then per hit its score, its session’s auth subject and title, LIVE when the session is live, and when the turn ran; the
turn’s prompt on a › line, the matched snippet on a dim » line, and the
working directory, harness, session, and trace on a last dim line. The list
ends by naming tapesctl sessions transcript <id> for the top session.
"how I fixed auth" · 2 hits across 2 sessions
0.82 local:ada · Auth fix LIVE Sep 17 › Fix OAuth redirect on staging » the redirect URI is per-environment /src/app · claude · session 01a0d365-1a2b-77a1-8473-bd2e295244a4 · trace t-10.77 local:bob Sep 12 › (synthetic turn) session 01a0d365-9c3d-77a1-8473-bd2e295244a4
read one tapesctl sessions transcript 01a0d365-1a2b-77a1-8473-bd2e295244a4A turn with an empty prompt renders as (synthetic turn); the server sends
the field even when blank precisely so the case stays distinguishable. Treat
printed scores as display values, not as exact numbers to assert on. spans get prints the span’s input and output documents whole after its
fields, never elided, so they copy as JSON.
An empty result set is not an error: non-quiet prints No results found.
and exits 0; quiet prints nothing and exits 0.
A deployment without span embeddings answers 503, and the body says which of
the two causes it is. It surfaces as tapes API at <host> answered 503 unavailable: <message> with a hint to try again in a minute.
-k -1 is refused by the parser, with clap’s unexpected argument '-1' found
and a -- -1 tip rather than a range complaint.
export
Section titled “export”Write a session’s export bundle — JSONL, one line per trace — to a file or stdout.
tapesctl export 01JDQ8F3K2M4N6P8R0T2V4X6Z8 -o bundle.jsonl --api-url http://localhost:8081| flag | default |
|---|---|
<SESSION_ID> |
required |
--detail <GRAIN> |
the server’s default, spans |
-o, --output <PATH> |
stdout |
--detail takes spans or traces, case-insensitively. Anything else fails
before the request:
tapesctl: invalid --detail "everything" (valid values: spans, traces)The body is streamed rather than buffered, and a non-success status is read and surfaced before any bytes are written, so an error page can never land in your output file. The bundle is written verbatim — the console and the importer both parse it, so even reserializing the JSON would break them.
With -o, the byte count goes to stderr, keeping stdout redirection clean —
the line is wrote <n> bytes to <path>. So
tapesctl export <id> -o f.jsonl > log captures nothing in log.
Populate a server with demo sessions so a fresh console has something to
render. POST /v1/admin/seed/demo — an admin route on the read API, not on
ingest.
tapesctl seed --api-url http://localhost:8081Seeded 4 sessions into http://localhost:8081/ ✓ 128 raw turns · 128 inserted · 0 dedupedEvery count is read defensively, so a server that trims a field cannot turn a
successful seed into a failure. Re-seeding reports everything deduped.
This writes into the server’s single-tenant org. It is not something to point
at a populated deployment. A server without the raw-turn layer answers 501,
surfaced with its body.
skills
Section titled “skills”Skills are served by the skills cassette, so the skills command is not
hand-built here: it is generated from the cassette’s own OpenAPI document at
invocation time, exactly like every other cassette command. Run
tapesctl skills --help against a deployment serving the cassette to see the
verbs it currently vends — a new cassette capability reaches this CLI with no
release. The retired local skill verb family (generate/list/sync,
which authored files under ~/.tapes/skills/) has been removed.
skills install
Section titled “skills install”The one skills verb this binary owns. It is mounted onto the cassette’s
skills command when the cassette is served (unless the cassette has an
install method of its own), and onto a bare skills command otherwise, and
it needs no server.
tapesctl skills install --source ~/src/skillstapesctl skills install --source https://example.com/skills.git --ref v2 --harness claudetapesctl skills install --source ~/src/skills --skill search --dry-run| flag | default | notes |
|---|---|---|
--source <PATH|GIT-URL> |
required (TAPESCTL_SKILLS_SOURCE) |
a directory holding skills/<name>/SKILL.md or <name>/SKILL.md, or a git URL cloned shallowly into a temporary directory |
--ref <REF> |
the remote’s default branch | branch or tag, for a git source |
--harness <H> |
every detected harness | repeatable: claude installs into ~/.claude/skills, codex into ~/.agents/skills; detected by ~/.claude / ~/.codex existing |
--skill <NAME> |
search, recall, digest |
repeatable |
--dry-run |
off | print what would be installed, write nothing |
Each skill is copied into a staging directory beside its destination and renamed into place; re-installing replaces the previous copy. A symbolic link inside a skill is refused rather than followed. There is no built-in source: which collection to recommend is a deployment’s choice.
plugin
Section titled “plugin”plugin install
Section titled “plugin install”tapesctl plugin install pitapesctl plugin install codex-app --dry-run| flag | default | applies to |
|---|---|---|
--dry-run |
off | all |
--port <N> |
a free port chosen and recorded at install time | hook-plugin harnesses only (codex-app) |
--codex-auth <M> |
chatgpt |
hook-plugin harnesses only |
--port and --codex-auth are refused, not ignored, for a file-copy
harness:
tapesctl: --port does not apply to pi, whose capture plugin is a file copy--codex-auth takes chatgpt or api-key; anything else gives invalid --codex-auth "X" (valid values: chatgpt, api-key).
Harnesses captured by redirection report that they need nothing, and exit 0:
claude needs no capture plugin — its traffic is captured by redirecting it, which `tapesctl start claude` does.Do not present plugin install as a required step for claude or codex.
The install is atomic: contents go to a staging file created exclusively,
permissions are set through the handle, superseded copies are removed, then the
file is renamed over the target — so no failure leaves a harness with a missing
or half-written plugin. Superseded copies are removed before the rename,
because pi loads every file in its extension directory into one process and a
stale copy under another name is a second reader contending for the same launch
nonce. Each removal prints tapesctl: removed superseded <path>.
The harness name is resolved before the machine is, so a typo neither reads
your home directory nor looks for codex on PATH.
plugin install opencode still works, even though start opencode is
withdrawn.
plugin uninstall
Section titled “plugin uninstall”One flag, --dry-run.
Uninstall is not complete removal for codex-app. The Codex plugin
registration survives and must be removed by hand; the command prints the
exact incantation:
dry run: would remove the "tapesctl-codex-app" provider from ~/.codex/config.tomldry run: would remove ~/.tapes/codex-appdry run: would leave the plugin registered with Codex; remove it with `codex plugin remove tapesctl-codex-app@tapesctl`Its --help says “and any configuration it wrote”, which overstates this.
plugin hook
Section titled “plugin hook”Hidden, and machine-only. It reports one lifecycle event to a running capture
proxy, is invoked by an installed hook plugin, and reads its event payload from
stdin — so a person typing it has nothing to pipe in. --handoff <PATH> is
required.
It is listed here so that finding it in a process list or in a Codex config identifies it, not so that you run it.
config
Section titled “config”Key and value, following git config and gh config rather than a flag per
setting. Needs no server — requiring --api-url to configure --api-url
would be a circle.
| leaf | args | behaviour |
|---|---|---|
set <KEY> <VALUE> |
both required | validates the key, then the URL scheme, then edits the file in place; prints <key> = <value> |
get [KEY] |
key optional | with a key, prints the value or nothing; without, prints every known and set key |
path |
none | prints the path whether or not the file exists |
tapesctl config set api-url http://localhost:8081tapesctl config get api-urltapesctl config path/Users/you/.tapes/config.tomlValidation, all exiting 1 and writing nothing:
tapesctl: unknown config key "tapes-erl" (known keys: api-url)tapesctl: invalid tapes URLtapesctl: api-url must be an http or https URL; "ftp" is not a scheme this client can callA known-but-unset key prints nothing and exits 0, so
$(tapesctl config get api-url) is empty rather than an error a script has
to special-case. That also means config get can print nothing from a file
that is not empty — only known and set keys are listed. See
Configuration.
version
Section titled “version”tapesctl versiontapesctl v0.7.0+3f2a1b9Sha: 3f2a1b9c0d4e5f60718293a4b5c6d7e8f9012345Built at: 2026-08-13T18:22:04ZAll in all, just another tape in the stereoFour lines, and all four are expected; the last is the release smoke test’s
canary and is pinned as an exact string. --version prints the same block
without it.
The identity comes from the build that produced the artifact, not from
Cargo.toml — the manifest holds a placeholder no release bumps, because a
release is cut by tagging a commit that has already merged, so the source
cannot know its own tag. A release reports v0.7.0+<commit>, a nightly
nightly+<commit>, and a local cargo build 0.0.0-dev+<commit>. The commit
rides along as semver build metadata, which comparison ignores — that is what
lets upgrade recognize a stamped v0.7.0+3f2a1b9 as the published v0.7.0.
A 0.0.0-dev in a bug report means a build from source, not a release.
upgrade
Section titled “upgrade”tapesctl upgrade # newest published releasetapesctl upgrade --version v0.6.0 # an exact release, older includedtapesctl upgrade --nightly # the rolling nightly buildReplaces this binary in place. With no flags it compares the installed version
against the published latest, prints tapesctl <version> is already up to date. and exits 0 when they match, and otherwise prints
tapesctl upgraded: <old> → <new>.
| flag | default | notes |
|---|---|---|
--version <spec> |
the newest release | v0.6.0 and 0.6.0 are the same target. Downgrades are allowed — a bad release needs an escape hatch |
--nightly |
off | always downloads; a nightly carries no orderable version to compare. Conflicts with --version |
Every failure leaves the binary you had still working. The order is fixed
and nothing is skipped: the published .sha256 is compared before the
downloaded file is made executable or run; the staged file is then probed with
version, which catches a faithfully published wrong-architecture artifact
that a correct digest cannot; its answer is checked against the resolved
version, which catches a prefix serving the wrong build; only then is the file
renamed over the installed binary, atomically. A missing .sha256 aborts —
there is no unverified-download fallback. Debris from a crashed run is swept at
the start of the next attempt, so repeated failures converge.
An install in a directory you cannot write — the pre-$HOME/.local/bin layout
— refuses before any network request and names the installer as the way to
migrate. upgrade never escalates.
| error | means |
|---|---|
install directory '<dir>' is not writable; re-run the installer to migrate: … |
an unmigrated root-owned install; refused before downloading |
no published checksum at '<url>' (answered <status>) — refusing to install an unverifiable binary |
no .sha256 sidecar was served. Any non-success status counts: a bucket without ListBucket answers 403 for a missing object |
sha256 mismatch: expected <a>, downloaded file hashes to <b> |
a corrupted or tampered download; nothing was executed |
staged binary '<path>' would not execute |
a wrong-architecture artifact |
staged binary '<path>' reports version <x>, expected <y> |
the bucket prefix is serving the wrong build |
uninstall
Section titled “uninstall”tapesctl uninstall # confirms firsttapesctl uninstall -y # no promptRemoves, in this order: ~/.tapes, the cassette cache, the installer’s PATH
block from .bashrc / .zshrc / config.fish, and finally the binary itself.
The prompt lists every one of those paths before you answer, because two of
them are recursive deletes.
The cassette cache it removes is the one tapesctl derived for itself, under the
platform cache directory. If TAPESCTL_CACHE_DIR is set, that location is
named in the output and left alone — the variable points at a directory you
chose, which may hold more than our cache, and an uninstall does not get to
recursively delete it on your behalf.
Each step warns and continues rather than aborting, so an interruption always
leaves a tapesctl that can be run again to finish. The rc-file edit removes
exactly the lines between the installer’s sentinel markers and preserves every
byte outside them; a block whose end marker is missing is reported and left
alone, because rewriting would drop whatever follows it. A paperctl block in
the same file is never touched.
Harness-side capture plugins are not removed — those are registrations in a
config file the harness owns. Use tapesctl plugin uninstall <harness>.
A prompt that reaches end-of-input counts as a decline, so a piped or
</dev/null invocation removes nothing.
cassettes
Section titled “cassettes”The command surface your deployment serves, discovered from the server at runtime. Covered in full in Cassettes.
tapesctl cassettes --help # what this server servestapesctl cassettes <name> --help # that cassette's methodstapesctl cassettes <name> <method> # call oneThe noun is always mounted, even with nothing under it, so tapesctl cassettes
is never an unknown-command error. Bare — with no subcommand — it exits 2,
naming the discovered set it wanted.
Error families
Section titled “Error families”Every runtime error exits 1 and prints a tapesctl: line on stderr,
followed by one indented caused by: line for each error beneath it. The
outermost line is the least specific — upgrade failed is a category — and the
cause you act on is usually the last line, so read the chain from the bottom.
| family | shape |
|---|---|
| unreachable server | could not reach the tapes API, the OS reason, and a hint: naming tapes serve, --api-url, and TAPES_API_URL when the connection was refused |
| non-success status | tapes API at <host> answered <status> <reason>: <message> (<code>) — the message and error fields of the server’s error document; a body that is not that document is shown on one line. A missing cassette adds a hint to run tapesctl cassettes |
| invalid flag value | invalid --<flag> "<value>" (valid values: …) — raised before any request |
| inapplicable flag | --<flag> does not apply to <harness>, … — refused, never silently ignored |
| unknown harness | unsupported harness "X" (supported: …) from start; unknown harness "X" (known: …) from capture |
| missing plugin | <harness> cannot be captured until its capture plugin is installed: … |
The no-server message names all three sources, and is the main place a user
learns config set exists.