Skip to content

How capture works

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.

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 200 is capturable — not “any 2xx”. A 201 or 204 is dropped.
  • Encoding. Only gzip, x-gzip, and zstd are decoded. A response in br or deflate drops 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.

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.

tapesctl sync is the backstop: it walks completed transcripts on disk and pushes them, for sessions no capture was running for.

Terminal window
tapesctl sync --ingest-url http://localhost:8082
Swept 2 sessions (2 files)
✓ 2 new · 0 unchanged
2 sessions queued for projection

The 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-days defaults 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 0 is the “everything” spelling. The window is a cost bound only, never a correctness one — widening it is always safe, because the server dedups.
  • sync reads Claude-shaped trees only. --harness-id (default claude) changes the label sessions are filed under, not what the sweep can read. Pointing --projects-root at a Codex or pi tree still finds zero sessions; rewrite that history into the ~/.claude/projects layout 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.

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:

  • claude and codex need no plugin. Running plugin install claude tells you so and exits 0 — the ordinary answer, not an error:

    claude needs no capture plugin — its traffic is captured by redirecting it, which `tapesctl start claude` does.
  • pi and opencode get 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 opencode is withdrawn and is refused exactly as an unknown name would be. Its registry entry and its plugin still work, and plugin install opencode still installs — only the start verb is gone, because on the OAuth path the plugin captures nothing. A capture that runs perfectly and records nothing is worse than a refusal.

pi is captured through an extension file, and tapesctl checks for it before anything is bound or spawned:

Terminal window
tapesctl start pi --ingest-url http://localhost:8082
tapesctl: 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:

Terminal window
tapesctl plugin install pi
tapesctl 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.

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:

Terminal window
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; --schema is not needed, and --schema anthropic is refused.

  • pi sends the model’s requests to <upstream>/chat/completions, and only /v1/chat/completions is captured, so --upstream names 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.json there — the model under openai, with api: "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.json and user-level instruction files (AGENTS.md and friends) are copied in from your agent directory ($PI_CODING_AGENT_DIR or ~/.pi/agent). auth.json, mcp.json and your own models.json are 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_KEY by 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/… and openai-codex/… name pi’s own models and are refused.

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:

Terminal window
tapesctl start hermes --ingest-url http://localhost:8082 --model <model-id>
  • The home is ~/.tapes/hermes-home, created 0700. 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, no auth.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 ~/.tapes instead, removed at exit, because sharing would rewrite the running hermes’s endpoint.
  • Each launch writes config.yaml (a custom provider 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_KEY for --schema openai (the default) or ANTHROPIC_API_KEY for --schema anthropic. When one is set it is written into config.yaml, and every file the launch writes is then created 0600. With neither, hermes sends no-key-required, which is right only for an --upstream that authenticates on its own.
  • --model is written to model.default. Without it hermes asks the endpoint’s /v1/models for 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. tapesctl files an auxiliary turn under its session but does not decode the metadata header, so aux_task is not in the ingest row’s harness metadata.
  • Switching provider inside hermes is refused, not uncaptured. Only the custom provider 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. Set TAPES_ALLOW_UNCAPTURED=1 to let such calls through, uncaptured.

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:

Terminal window
tapesctl plugin install codex-app
tapesctl capture codex-app --ingest-url http://localhost:8082
capturing codex-app on 127.0.0.1:64513 — start a session in the app; Ctrl-C to stop

plugin 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 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 unknown

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

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.

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:

Terminal window
tapesctl sessions get f47ac10b-58cc-4372-a567-0e02b2c3d479 --api-url http://localhost:8081
tapesctl: 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:

Terminal window
tapesctl sessions list --harness-id claude \
--harness-session-id f47ac10b-58cc-4372-a567-0e02b2c3d479 \
--api-url http://localhost:8081 | jq -r '.items[].id'
01JDQ8F3K2M4N6P8R0T2V4X6Z8

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

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

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