The two capture lanes, which harness uses which mechanism, what attribution means, and why the session id start prints is not the one read commands take.
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 {ingest-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 {ingest-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 --ingest-url http://localhost:8082Swept 2 sessions (2 files) ✓ 2 new · 0 unchanged 2 sessions queued for projectionThe third line is deliberately narrower than “projected.” Every session with
at least one accepted or already-present file has projection queued by ingest,
but that work runs asynchronously. sync does not poll the read API, and a
read immediately afterwards may still show the previous projection. An
already-present file is still a successful upload and requeues projection
server-side.
Global -v adds one line per file, outcome first (new, unchanged,
failed, or unknown), then its harness session id, path, and the record
count reported by the server. A failed request has no server count and prints
? records. For a successful response, the acknowledgement fields are
independent: an omitted record count prints ? records without hiding a known
outcome, while an omitted dedup status prints unknown without hiding a known
count. Normal mode omits per-file detail. Unlike 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.syncreads Claude-shaped trees only.--harness-id(defaultclaude) changes the label sessions are filed under, not what the sweep can read. Pointing--projects-rootat a Codex or pi tree still finds zero sessions; rewrite that history into the~/.claude/projectslayout and record shape first, then sync it with the matching--harness-id.
Re-running sync is cheap and safe: the ingest endpoint keys rows on a content
hash, so the summary counts unchanged content as already present. 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.
Syncing an old transcript is partial historical reconstruction, not a replacement for capture. When a session has no usable wire calls, its transcript can still produce partial, browsable LLM calls and causal structure. Those transcript-derived calls lack full wire fidelity: exact provider requests and response bytes are unavailable, and the harness transcript can omit some side calls or context.
The fallback is session-wide. If usable wire capture arrives later, projection switches to the wire call inventory; the transcript continues to reconcile causal structure, while wire-derived calls replace and prune the partial transcript projection instead of appearing beside it as duplicates.
Any undelivered transcript makes sync exit 1, deliberately, because sync
is an explicit request to move data. Both status lines still print before the
command returns its final error, and everything that did land is durable. The
queued-session count includes only
unique sessions with at least one successful response; a wholly failed session
is not claimed as queued.
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 |
hermes |
planned HERMES_HOME (config + plugin) |
none | 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: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 --ingest-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 --ingest-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.
While captured, pi refuses any model whose requests would not reach the capture
proxy — a provider the extension does not redirect, or a models.json provider
with its own baseUrl — with one line naming the provider and host, rather than
letting the session run uncaptured. Set TAPES_ALLOW_UNCAPTURED=1 to let such
requests go to their own endpoint, uncaptured, with a warning.
Capturing pi on a model it does not ship
Section titled “Capturing pi on a model it does not ship”An upstream serving a self-hosted OpenAI-compatible model has an id no pi
catalog carries, and pi’s openai provider speaks the Responses API by default.
tapesctl’s own --model declares the model for the session:
tapesctl start pi --ingest-url http://localhost:8082 \ --upstream http://localhost:8000/v1 --model <model-id>-
The capture fronts
openai, since the declared model speaks Chat Completions;--schemais not needed, and--schema anthropicis refused. -
pi sends the model’s requests to
<upstream>/chat/completions, and only/v1/chat/completionsis captured, so--upstreamnames the server’s OpenAI-compatible base, ending in/v1. A bare origin (http://localhost:8000) gains the/v1; an upstream with any other path is refused rather than launched into a session that captures nothing. -
pi runs in an agent directory of its own,
~/.tapes/pi-agent(0700, persistent, so its sessions carry over), never your~/.pi/agent. A second live launch gets a private temporary directory beside it. -
Each launch writes
models.jsonthere — the model underopenai, withapi: "openai-completions", at the capture proxy — and the capture extension, selects the model with--model openai/<id>, and removes both at exit. The installed plugin is not needed for this launch. -
Your
settings.json,keybindings.jsonand user-level instruction files (AGENTS.mdand friends) are copied in from your agent directory ($PI_CODING_AGENT_DIRor~/.pi/agent).auth.json,mcp.jsonand your ownmodels.jsonare not, so pi’s saved logins are not available in this session; pi’s other providers still read their environment variables. -
The credential is
$OPENAI_API_KEYby reference when that variable is set — pi reads it itself and the value is never written — otherwise a placeholder, which is right for an upstream that authenticates on its own. -
An
openai/prefix and a:<thinking>suffix are accepted and dropped;anthropic/…andopenai-codex/…name pi’s own models and are refused.
Capturing hermes
Section titled “Capturing hermes”hermes keeps its endpoint in $HERMES_HOME/config.yaml, so start hermes
runs it in a home of its own rather than redirecting a flag:
tapesctl start hermes --ingest-url http://localhost:8082 --model <model-id>- The home is
~/.tapes/hermes-home, created0700. It persists between launches, so hermes’s sessions, memories, and skills carry over from one captured run to the next — but it is not your~/.hermes, and nothing is copied from there: no.env, noauth.json, no history. - One live launch holds that home at a time. A second hermes launch while the
first is running — including hermes started from inside a captured hermes
session — runs in a private temporary home under
~/.tapesinstead, removed at exit, because sharing would rewrite the running hermes’s endpoint. - Each launch writes
config.yaml(acustomprovider at the capture proxy, no other provider and no fallback) and the capture plugin into it, and removes them at exit. Nothing has to be installed first. - The credential is the one your environment already holds for the schema:
OPENAI_API_KEYfor--schema openai(the default) orANTHROPIC_API_KEYfor--schema anthropic. When one is set it is written intoconfig.yaml, and every file the launch writes is then created0600. With neither, hermes sendsno-key-required, which is right only for an--upstreamthat authenticates on its own. --modelis written tomodel.default. Without it hermes asks the endpoint’s/v1/modelsfor a default, which the upstream may or may not answer.
hermes’s plugin stamps the envelope and echoes the launch nonce exactly as pi’s
extension does, so its turns are filed under the session hermes names. That
includes hermes’s auxiliary calls — title generation, compression, vision, web
extraction, session search, and the rest — which bypass hermes’s request
middleware; the plugin attributes them separately, with the same envelope and
nonce plus x-tapes-harness-metadata naming the task ({"aux_task": …}).
Three caveats:
- An auxiliary call can attach to the wrong session in a busy process. It is given the session the plugin last saw in its context, or the process’s most recent one when it runs on a thread that inherited none (title generation does). In a hermes process running several sessions at once, such a call may be filed under the most recent rather than its own.
- The task name stays on the forwarded request.
tapesctlfiles an auxiliary turn under its session but does not decode the metadata header, soaux_taskis not in the ingest row’s harness metadata. - Switching provider inside hermes is refused, not uncaptured. Only the
customprovider points at the proxy, and the plugin refuses any main-loop or auxiliary call whose base URL is elsewhere, with one line naming the host. SetTAPES_ALLOW_UNCAPTURED=1to let such calls through, uncaptured.
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 --ingest-url http://localhost:8082capturing 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:
captured 12 turns (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.
Launch lineage
Section titled “Launch lineage”A captured harness can start another one — a shell tool in a Claude session
runs tapesctl start hermes. Every start exports TAPES_LAUNCH_ID and
TAPES_LAUNCH_HARNESS_ID to its harness, and a start that finds them in its
own environment knows which launch it is running inside.
To turn that launch into a session, each launch writes
~/.tapes/launches/<launch-id>.json (0600, in a 0700 directory) naming the
session its harness is currently using, rewrites it when that changes, and
deletes it when the harness exits. A file rather than a socket because every
start has its own short-lived proxy and there is no daemon to ask; the file
needs no listener and cannot stall the outer capture.
A nested launch reads its parent’s record when its first attributed turn
lands and stamps that session on its turns as
x-tapes-parent-harness-session-id, with x-tapes-parent-harness-id naming
the parent’s harness. A turn whose own envelope already names a parent — a
hermes or Codex subagent — keeps its own. The ingest row carries the parent
as parent_harness_session_id, and for a parent in another harness also
parent_harness_id, so the server resolves it in the parent’s own harness.
A same-harness parent omits parent_harness_id: absence means “the child’s
harness”.
A tapes that predates parent_harness_id ignores the field and would look a
cross-harness parent up in the child’s harness, filing the child under a
placeholder parent there. So the first turn that would carry one asks the
ingest’s GET /ping which capabilities it has, once per launch. Unless the
answer lists parent_harness_id, the cross-harness parent is left off the
ingest row — the child is captured, unlinked, exactly as before lineage
existed — and start says so once in its log and once on stderr at exit. A
failed or slow probe counts as “not listed”. Same-harness parents never need
the probe and are always sent.
None of this is a trust signal: the variables and the record are readable and forgeable by anything running as you. A forged lineage can only name a wrong parent; it never changes which session a turn is filed under.
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 --api-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 \ --api-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”Beyond the log file below: ~/.tapes/launches/<launch-id>.json while a
harness runs (see Launch lineage), and for hermes the
capture home ~/.tapes/hermes-home (see Capturing hermes).
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.