How capture works
Capture is two independent lanes recording the same session, plus a sweep that picks up what neither lane was running for. Understanding which lane a harness gets — and which of them is optional — explains most of what you will see in the console, and all of what you will not.
The capture commands on this page — start, capture, and sync — send to
the ingest port (8082 on a local tapes serve), never the read port; the
read examples further down use the read port. See
The two ports.
The two lanes
Section titled “The two lanes”Lane A — the wire
Section titled “Lane A — the wire”tapesctl start launches the harness with its LLM endpoint pointed at a
capture proxy on 127.0.0.1, on an ephemeral port chosen per launch. Every
eligible call the harness makes is forwarded to the real provider byte for
byte, and a copy is posted to POST {tapes-url}/v1/ingest.
This lane is start. It cannot be turned off, and it is what makes a
session exist at all.
What counts as eligible is a fixed list, and the exclusions matter more than the inclusions:
- Paths. Only these suffixes are turn-eligible:
/v1/chat/completions,/v1/responses,/codex/responses,/v1/messages,/api/chat. Health probes, model listings, and token counting are forwarded but never recorded. - Status. Only HTTP
200is capturable — not “any 2xx”. A201or204is dropped. - Encoding. Only
gzip,x-gzip, andzstdare decoded. A response inbrordeflatedrops the turn. - Size. Requests are peeked up to 4 MiB and responses read up to 8 MiB. A body that exactly fills the request window is treated as incomplete and silently uncaptured.
A dropped turn is logged at debug and is invisible at the default verbosity.
That is deliberate: a capture failure must never take the harness down, so
nothing on this lane is allowed to raise an error into your session. It also
means “the console is missing a call I know I made” is a question you answer
by re-running with -v, not by reading the exit summary.
There is no retry. A turn the ingest server rejects is warned about and gone.
Lane B — the transcript
Section titled “Lane B — the transcript”Harnesses that keep their own on-disk transcript get a second lane: a tailer
watches that tree while the session runs and posts settled records to
POST {tapes-url}/v1/ingest/transcript.
Lane B carries the session’s causal skeleton — which Task tool call
forked which subagent. Lane A cannot: on the wire, a subagent’s calls are
indistinguishable from the main thread’s, because they are the same shape of
request to the same endpoint.
The cost of losing lane B is specific rather than vague. A measured pair of identical Claude subagent runs recorded comparable wire lanes — 38 and 40 turns — and 8 versus 0 transcript turns. Both sessions contain every call the subagents made. Only one renders that work as nested rows; the other shows flat dispatch text.
Pass --no-transcripts only when another capture client is already tailing the
same tree, since two tailers on one tree is the problem it exists to solve.
The sweep
Section titled “The sweep”tapesctl sync is the backstop: it walks completed transcripts on disk and
pushes them, for sessions no capture was running for.
tapesctl sync --tapes-url http://localhost:8082tapesctl: swept 2 session(s), 2 file(s): 2 stored, 0 deduped, 0 failedUnlike start, sync logs to stderr as usual — only start diverts its
diagnostics to a file, and only because a harness owns the terminal.
Two things about sync are not visible from its help text:
--since-daysdefaults to 7. The help shows no default and the parsed value is genuinely absent, but an absent value maps to seven days. Sweeping a transcript tree older than a week gives you a silent partial sweep.--since-days 0is the “everything” spelling. The window is a cost bound only, never a correctness one — widening it is always safe, because the server dedups.synccan only file Claude sessions. The harness id it stamps is hardcoded. Pointing--projects-rootat a Codex or pi tree will not do what the flag name suggests.
Re-running sync is cheap and safe: the ingest endpoint keys rows on a content
hash, so an unchanged transcript comes back deduped. tapesctl keeps no
client-side ledger of what it has already sent, by design — a ledger that
disagreed with the server would be worse than no ledger.
Any undelivered transcript makes sync exit 1, deliberately, because sync
is an explicit request to move data. The summary line still prints first, and
everything that did land is durable.
Which harness uses which mechanism
Section titled “Which harness uses which mechanism”There are three capture mechanisms, and a harness is eligible for exactly one.
| harness | mechanism | plugin needed first | lane B | start |
|---|---|---|---|---|
claude |
endpoint redirect (ANTHROPIC_BASE_URL) |
none | yes | supported |
codex |
argv provider overrides | none | yes | supported |
pi |
installed extension | yes — plugin install pi |
no | supported |
codex-app |
lifecycle hooks | yes — plugin install codex-app |
no | use capture |
opencode |
installed plugin | plugin install opencode |
no | withdrawn |
Three consequences worth stating plainly:
-
claudeandcodexneed no plugin. Runningplugin install claudetells you so and exits0— the ordinary answer, not an error:tapesctl: claude needs no capture plugin — its traffic is captured by redirecting it, which `tapesctl start claude` does. -
piandopencodeget no transcript lane at all. Neither keeps a transcript tree the tailer can read; opencode keeps sessions in SQLite. Their subagent structure is not recoverable. -
start opencodeis withdrawn and is refused exactly as an unknown name would be. Its registry entry and its plugin still work, andplugin install opencodestill installs — only thestartverb is gone, because on the OAuth path the plugin captures nothing. A capture that runs perfectly and records nothing is worse than a refusal.
Install the plugin before capturing pi
Section titled “Install the plugin before capturing pi”pi is captured through an extension file, and tapesctl checks for it before
anything is bound or spawned:
tapesctl start pi --tapes-url http://localhost:8082tapesctl: pi cannot be captured until its capture plugin is installed: no plugin at ~/.pi/agent/extensions/tapes-gateway.ts. Run `tapesctl plugin install pi` first.So the order is fixed, once per machine:
tapesctl plugin install pitapesctl start pi --tapes-url http://localhost:8082 -- --provider anthropic --model <model-id>--provider and --model are pi’s own flags, which is why they follow --.
Pass both or neither: pi only honours them as a pair, and given one it falls
back to a saved default that may be a provider this capture does not front — so
the session runs and records nothing.
Capturing an app that launches itself
Section titled “Capturing an app that launches itself”An app started from the dock has no process for start to own, so codex-app
uses lifecycle hooks and a long-lived proxy instead. Install once, then run
capture for as long as you want the app recorded:
tapesctl plugin install codex-apptapesctl capture codex-app --tapes-url http://localhost:8082tapesctl: capturing codex-app on 127.0.0.1:64513 — start a session in the app; Ctrl-C to stopplugin install packages the hook plugin under ~/.tapes/codex-app/, writes a
handoff file, points ~/.codex/config.toml at a loopback port fixed at install
time, and prints the codex plugin commands that register it. That endpoint
outlives any one capture, which is why the port cannot be ephemeral the way
start’s is — pass --port to pin it.
Running capture codex-app before installing tells you so:
tapesctl: could not read the codex-app handoff at ~/.tapes/codex-app/handoff.json: run `tapesctl plugin install codex-app`If the handoff and the app’s configuration disagree about the address,
capture refuses rather than warning. A capture bound to one address while the
app talks to another would run perfectly and record nothing.
capture prints no turn counts and no unattributed warning. Its tally is
constructed but never drained, so unlike start it can tell you a session
began but not how much of it landed. Do not describe it as “start for apps you
launch yourself” without that caveat.
Attribution
Section titled “Attribution”Attribution is how a captured turn gets filed under the session it belongs to.
The wire lane sees an HTTP request; it does not see a session. Something has to
supply that identity, and what supplies it differs by harness — Claude’s is read
from ~/.claude/sessions/<pid>.json, pi’s arrives in an
X-Tapes-Harness-Session-Id header.
When that lookup fails, the turn is still captured. It is filed under unknown
rather than dropped, and start says so on stderr:
tapesctl: warning: 3 captured turns could not be attributed to this session and were filed as unknownThis warning fires whenever any turn was unattributed — including the mixed case where a session link is also printed. The link is true and incomplete at the same time: some of that session’s turns are not under it.
If nothing was attributed, there is no session to link, and the summary says what did happen instead:
tapesctl: captured 12 turn(s) (3 unattributed — filed as unknown)That is an attribution bug being reported as one, rather than as silence. It is
distinct from tapesctl: no turns were captured, which means the wire lane saw
nothing eligible — a harness launched and quit without calling a model.
Session ids
Section titled “Session ids”The session id start prints is not the id read commands take. This is a
live defect; until it is fixed, the --harness-id/--harness-session-id
filter pair below is the bridge between the two.
start prints, and builds its console link from, the harness’s session id:
Claude’s own UUID, or pi’s header value. The read API keys sessions on a
different value — the tapes session id — and carries the harness one alongside
it as harness_session_id. Feeding the printed id to a read command fails:
tapesctl sessions get f47ac10b-58cc-4372-a567-0e02b2c3d479 --tapes-url http://localhost:8081tapesctl: tapes API returned 404 for http://localhost:8081/v1/sessions/f47ac10b-58cc-4372-a567-0e02b2c3d479: {"error": "session not found", "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479"}To get from a printed id to a usable one, hand it to sessions list as the
--harness-session-id filter — paired with --harness-id, the harness you
launched — and read the id on the result:
tapesctl sessions list --harness-id claude \ --harness-session-id f47ac10b-58cc-4372-a567-0e02b2c3d479 \ --tapes-url http://localhost:8081 | jq -r '.items[].id'01JDQ8F3K2M4N6P8R0T2V4X6Z8The filter is the read API’s own harness_session_id parameter on
/v1/sessions, applied server-side; a printed id that matches nothing returns
an empty items. The server accepts the harness filter only whole — a lone
half is a 400 — so tapesctl requires the pair too, failing at parse with the
missing half named. The server-side pairing requirement is expected to relax in
a future tapes release, at which point the flag can stand alone.
A second-order effect is worth knowing when the printed id looks wrong: the id
start prints is whichever attributed turn the ingest server accepts first.
If a subagent’s turn lands ahead of the main thread’s, the printed id names the
sub-thread. Listing sessions is reliable where the printed line is not.
What a capture writes locally
Section titled “What a capture writes locally”While a harness holds the terminal, nothing may reach stdout or stderr — a
stray log line lands in the middle of a TUI frame. So start at default
verbosity writes its diagnostics to a file:
~/.tapes/logs/start-YYYYMMDD-HHMMSS-<pid>.logThe path is printed before launch and again at exit. Pass -v to stream to
stderr instead, accepting what that does to the display; that is the documented
way to watch a capture live.
There is no stderr fallback if the log file cannot be opened. The run prints
tapesctl: diagnostics disabled — no log file (…) once and then discards
events, because a corrupted TUI is judged more costly than a lost debugging
session.
start does not propagate the harness’s exit status. A non-zero child is
warned about, and tapesctl start claude … still exits 0. CI wrappers that
treat start as transparent to exit status will get this wrong.