# Capture an agent

> Point Claude, Codex, pi, or a generic provider client at capture: tapesctl start/capture vs a fixed-port proxy on :8080, always writing ingest on :8082.

- Repository: papercomputeco/tapes
- GitHub: https://github.com/papercomputeco/tapes
- Human docs: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750
- Complete Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/llms-full.txt

## Source Files

- `docs/integrations.md`
- `proxy/proxy.go`
- `cmd/tapes/serve/proxy/proxy.go`
- `pkg/capture/reducer.go`
- `ingest/openapi.go`
- `cmd/tapes/auth/auth.go`

---

---
title: "Capture an agent"
description: "Point Claude, Codex, pi, or a generic provider client at capture: tapesctl start/capture vs a fixed-port proxy on :8080, always writing ingest on :8082."
---

`tapesctl start` and `tapesctl capture` write completed turns to the **private ingest API** (`POST /v1/ingest`, `POST /v1/ingest/transcript`) on `:8082` by default. The all-in-one `tapes serve` process also binds a **fixed-port transparent proxy** on `:8080` for Anthropic, OpenAI, or Ollama clients that you point at the proxy yourself. Those two lanes share `raw_turns` and the deriver; they are not the same listen address and they are not the read API on `:8081`.

<Warning>
Capture commands must target ingest (`http://localhost:8082`). A `tapesctl start` / `capture` / `sync` pointed at `:8081` reports success and stores nothing.
</Warning>

## Ports

`tapes serve` starts the proxy, read API, ingest, in-process derive worker, and (by default) the embed worker together.

| Listen | Surface | Who talks to it |
| --- | --- | --- |
| `:8080` | Transparent provider proxy | Agent or app whose **base URL** is the proxy. Forwards to `proxy.upstream`. |
| `:8081` | Read API | `tapesctl sessions`, `search`, `export`, `seed`; `GET /ping` |
| `:8082` | Private ingest API | `tapesctl start`, `capture`, `sync`; `tapes-extproc` / other capture adapters |

Defaults: `proxy.provider=ollama`, `proxy.upstream=http://localhost:11434`. Change them for a hosted provider:

```bash
tapes serve --provider anthropic --upstream https://api.anthropic.com
```

<ParamField body="--provider" type="string">
LLM wire schema the **fixed-port** proxy parses: `anthropic`, `openai`, or `ollama`. Default `ollama`.
</ParamField>

<ParamField body="--upstream" type="string">
Upstream base URL the **fixed-port** proxy forwards to. Default `http://localhost:11434`.
</ParamField>

<ParamField body="--proxy-listen / -p" type="string">
Proxy listen address. Default `:8080`.
</ParamField>

<ParamField body="--ingest-listen / -i" type="string">
Ingest listen address. Default `:8082`.
</ParamField>

<ParamField body="--tapes-url" type="string">
`tapesctl` server URL. Flag beats `TAPES_URL`, which beats `~/.tapes/config.toml`. Capture commands need ingest; read commands need the read API.
</ParamField>

Configure the client once for **read** commands, and pass ingest explicitly when capturing:

```bash
tapesctl config set tapes-url http://localhost:8081
```

```mermaid
flowchart LR
  subgraph agents [Agents]
    harness["claude / codex / pi"]
    generic["Generic provider client"]
  end

  subgraph client [tapesctl]
    jit["Just-in-time capture proxy"]
    window["capture window"]
  end

  subgraph serve ["tapes serve"]
    proxy[":8080 proxy"]
    ingest[":8082 ingest"]
    api[":8081 read API"]
    raw[("PostgreSQL raw_turns")]
    derive["derive worker"]
  end

  harness --> jit
  harness --> window
  jit -->|"POST /v1/ingest and /v1/ingest/transcript"| ingest
  window -->|"POST /v1/ingest and /v1/ingest/transcript"| ingest
  generic --> proxy
  jit --> upstream["Upstream provider"]
  proxy --> upstream
  proxy --> raw
  ingest --> raw
  raw --> derive
  derive --> api
```

The `:8080` proxy persists through its worker pool into the same Postgres `raw_turns` layer ingest writes. It does not HTTP-POST to `:8082`. `tapesctl` and gateway adapters (`tapes-extproc`) do.

## Prerequisites

<Steps>
<Step title="Install both binaries">
`tapes` is the server. `tapesctl` is the client (separate repository). Client commands that used to live on `tapes` are not on this binary.

```bash
curl -fsSL https://download.tapes.dev/install | bash
curl -sSfL https://download.tapes.dev/tapesctl/install | bash
```
</Step>
<Step title="Start local dependencies and the stack">
```bash
tapes local up
tapes serve
```

`tapes local up` writes PostgreSQL / pgvector / Ollama settings into the active `.tapes/config.toml`. It pulls the default embedding model (`embeddinggemma`), not every chat model.
</Step>
<Step title="Confirm the three surfaces">
```bash
curl http://localhost:8081/ping
curl http://localhost:8082/ping
tapes status
```

Read-API `GET /ping` is not a proxy or ingest health check.
</Step>
</Steps>

<Note>
`tapes auth` stores OpenAI or Anthropic keys in `.tapes/credentials.toml` for **span embedding** and **skill generation**. Capture is transparent: the agent forwards its own credentials. Do not treat `tapes auth` as a capture prerequisite.
</Note>

## Two capture lanes

| Lane | Command | Listen | Write path | Use when |
| --- | --- | --- | --- | --- |
| Just-in-time | `tapesctl start <harness>` | Ephemeral loopback proxy owned by `tapesctl` | `POST` ingest `:8082` | The client launches the agent (`claude`, `codex`, `pi`) |
| Capture window | `tapesctl capture <plugin>` | Bind address from the plugin handoff | `POST` ingest `:8082` | The agent launches itself (`codex-app`) |
| Fixed-port | `tapes serve` (or `tapes serve proxy`) | `:8080` | Proxy worker pool → Postgres `raw_turns` | Generic client, long-lived `ANTHROPIC_BASE_URL` / provider base URL |

`start` prints turn counts and the **harness** session id on exit. `capture` prints how many sessions it saw, not turn counts. Neither printed id is the Tapes session id that `tapesctl sessions get` takes — list sessions on `:8081` and copy the id from there.

## Harness matrix

| Harness | Lane | Plugin first |
| --- | --- | --- |
| `claude` | `tapesctl start claude` | none |
| `codex` | `tapesctl start codex` | none |
| `pi` | `tapesctl start pi` | `tapesctl plugin install pi` |
| `codex-app` | `tapesctl capture codex-app` | `tapesctl plugin install codex-app` |

`--schema` is valid only on `pi`. Passing `--schema` to `claude` or `codex` is an error: each of those harnesses speaks exactly one schema.

<Tabs>
<Tab title="Claude">

```bash
tapesctl start claude --tapes-url http://localhost:8082
```

`tapesctl` starts a loopback capture proxy, sets Claude Code's `ANTHROPIC_BASE_URL` to it, launches `claude`, and POSTs captured turns to ingest. Agent flags go after `--`:

```bash
tapesctl start claude --tapes-url http://localhost:8082 -- --worktree
```

Claude also writes transcripts on disk. Those rows carry subagent fork edges the wire traffic alone cannot show (`tool_use_id` / per-agent files). `start` tails them live. For a session that ran without capture:

```bash
tapesctl sync --tapes-url http://localhost:8082
```

`sync` sweeps the last seven days by default. `--since-days 0` sweeps everything. Re-push is safe: transcript ingest deduplicates on a content hash (`deduped=true` when unchanged).

Fixed-port alternative (no `tapesctl` proxy):

```bash
tapes serve --provider anthropic --upstream https://api.anthropic.com
ANTHROPIC_BASE_URL=http://localhost:8080 claude
```

</Tab>
<Tab title="Codex CLI">

```bash
tapesctl start codex --tapes-url http://localhost:8082
```

Same just-in-time pattern as Claude. Codex subagent identity on the wire is the `session-id` / `thread-id` pair: root turns have `thread-id == session-id` and resolve to an empty capture `thread_id`; only spawned threads keep a distinct id.

</Tab>
<Tab title="Codex desktop">

The ChatGPT desktop app launches itself, so the lane is a capture window, not `start`.

```bash
tapesctl plugin install codex-app
tapesctl capture codex-app --tapes-url http://localhost:8082
```

`plugin install` writes the handoff and points the app's Codex configuration at the capture address. `capture` reads that handoff and binds it. Running `capture` before install fails and tells you to install.

`plugin uninstall codex-app` removes the provider entry and the handoff. The plugin stays registered with Codex; the command prints the `codex plugin remove` line that finishes uninstall.

</Tab>
<Tab title="pi">

pi is captured by an installed extension. Install is required, not optional: `start pi` refuses to run when the extension is missing, before anything is bound.

```bash
tapesctl plugin install pi
tapesctl start pi --tapes-url http://localhost:8082
```

pi can redirect several providers through one endpoint, so it is the only harness that takes `--schema`:

```bash
tapesctl start pi --tapes-url http://localhost:8082 --schema openai
```

</Tab>
</Tabs>

## Generic clients on `:8080`

Any Anthropic-, OpenAI-, or Ollama-compatible application can use the fixed-port proxy. Set the client's provider base URL to `http://localhost:8080` and run `tapes serve` with the matching `--provider` and `--upstream`. Preserve the path convention the client and provider expect.

Default stack (Ollama-compatible):

```bash
tapes serve
curl http://localhost:8080/api/chat \
  -H 'Content-Type: application/json' \
  -d '{"model":"qwen3-coder:30b","messages":[{"role":"user","content":"hello"}],"stream":false}'
```

Pull the chat model yourself. `tapes local up` does not pull completion models.

<CodeGroup>

```bash Anthropic
tapes serve --provider anthropic --upstream https://api.anthropic.com
# point the client at http://localhost:8080
```

```bash OpenAI-compatible
tapes serve --provider openai --upstream https://api.openai.com
# point the client at http://localhost:8080
```

```bash Ollama
tapes serve --provider ollama --upstream http://localhost:11434
# point the client at http://localhost:8080
```

</CodeGroup>

Optional request header `X-Tapes-Agent-Name` tags the turn. The proxy is otherwise transparent: it copies client headers upstream (harness thread headers are observed, not stripped).

The local proxy attaches an **empty** session envelope. Ingest/session-aware persist then mints a `sessions` row keyed by a synthetic `harness_session_id` derived from the turn's Merkle root, so generic `:8080` traffic still appears in the read API. Harness captures from `tapesctl` send a real session envelope instead.

<Info>
Embeddings are **not** written at capture time. `tapes serve embed-worker` (on by default inside `tapes serve`) is the single writer. Embedding flags on `tapes serve proxy` are accepted and ignored.
</Info>

## What ingest accepts

Ingest is a separate sealed contract (`GET http://localhost:8082/openapi`). It is not internet-facing: it trusts org identity in the envelope / gateway headers (`x-paper-auth-org-id`, `x-paper-auth-subject`). Legitimate callers are in-cluster adapters (`tapesctl`, `tapes-extproc`, paperd).

| Method | Path | Success | Notes |
| --- | --- | --- | --- |
| `GET` | `/ping` | `200` `{"status":"ok"}` | Process is serving; does not check Postgres |
| `POST` | `/v1/ingest` | `202` `{"status":"accepted"}` | One completed LLM turn |
| `POST` | `/v1/ingest/transcript` | `202` `{status, deduped, records, agent_id?}` | Main transcript, one subagent file, or Codex spawn-anchor row |
| `GET` | `/openapi` | `200` | This surface's contract only |

:::endpoint POST /v1/ingest Append one completed turn
The raw envelope is persisted **before** provider parse, so a parse failure is still captured. Idempotent when the adapter supplies `meta.request_id`.

**Body (`TurnPayload`)** — `provider` (`openai` \| `anthropic` \| `ollama`), `request` (raw provider JSON), optional reduced `response`, optional `raw_response` (verbatim upstream bytes, base64 in JSON), optional `raw_response_encoding`, optional `raw_response_withheld`, `meta`, optional `session`, optional `agent_name`.

**Errors:** `400` malformed envelope or session block · `413` over the body cap · `422` well-formed but unprocessable (unknown provider) · `502` downstream / worker saturation.

Raw-only payloads are reduced server-side with the shared `pkg/capture` reducers so two adapters can land identical rows for identical traffic.
:::

:::endpoint POST /v1/ingest/transcript Append one harness transcript
Dedup key includes a content hash of `records`. Unchanged re-upload → `deduped=true`. A grown file appends a new version. The deriver reads the latest version per `(session, agent, lifecycle kind)`.

`kind` empty = Codex spawn evidence. `kind=interacted` is stored and ignored by derivation so it cannot supersede a spawn anchor. `501` if the driver has no raw-turn layer.
:::

Body ceiling is `MaxIngestBodyBytes` = 32 MiB decoded request + base64 of an 8 MiB raw response + 4 MiB reserve (~46.67 MiB). The proxy rejects oversized **provider** requests with HTTP `413` on the same constant so the capture layer is not a tighter gate than Anthropic's 32 MB request contract. Oversize ingest POSTs use the JSON error envelope `{"error":"..."}` and increment `tapes_ingest_writes_total{provider="unknown",status="reject_oversize"}`.

The proxy HTTP client timeout is **5 minutes**. Capture jobs that miss the worker pool (256 slots, 256 MiB retained-byte budget, 3 workers) are dropped on the proxy path or returned as `502` on ingest.

## Verify

```bash
curl http://localhost:8081/ping
tapes status
tapesctl sessions list --tapes-url http://localhost:8081
```

A captured session appears in that list after derive runs (in-process under `tapes serve`). Use the listed Tapes session id for `sessions get`, `traces`, `raw-turns`, and export — not the harness id `start` printed.

Stop foreground `tapes serve` with `Ctrl-C`. `tapes local down` removes bootstrap containers and keeps PostgreSQL data unless `--wipe` is supplied.

## Failure modes

| Symptom | Cause | Fix |
| --- | --- | --- |
| Capture "succeeds", `sessions list` empty | `--tapes-url` / `TAPES_URL` / config pointed at `:8081` | Pass `http://localhost:8082` on `start` / `capture` / `sync` |
| `start pi` refuses before bind | Extension missing | `tapesctl plugin install pi` |
| `capture codex-app` fails immediately | Plugin / handoff missing | `tapesctl plugin install codex-app` first |
| `--schema` rejected | Used on `claude` or `codex` | Only `pi` accepts `--schema` |
| `sessions get` 404 on the printed id | That id is the harness session id | Copy the id from `tapesctl sessions list` |
| Ingest `413` | Envelope over `MaxIngestBodyBytes` | Shrink the turn, or expect `raw_response_withheld` / dropped raw bytes |
| Generic client talks to `:8081` or `:8082` | Those are Tapes contracts, not provider APIs | Set the provider **base URL** to `:8080` |
| Compose capture from the host cannot reach ingest | `docker-compose.yaml` publishes `8080` and `8081` only | Publish `8082` or run `tapes serve` on the host |
| No search hits after capture | Embed worker off or wrong embed keys | Capture still landed; see embedding config. Capture does not need `tapes auth` |

<AccordionGroup>
<Accordion title="What the fixed-port proxy actually captures">
Only `POST` requests with a non-empty body are treated as chat turns. The handler parses with `proxy.provider`, then streams or one-shots to `proxy.upstream + request path`. Successful `200` chat responses are enqueued asynchronously; the client already has the upstream bytes. Failed parse of the response skips enqueue (request parse failure still forwards).
</Accordion>
<Accordion title="Thread headers the proxy observes">
Claude Code: `x-claude-code-agent-id` (absent on the main thread). Codex: `session-id` + `thread-id`; sub-thread only when they differ and both are present. These headers stay on the upstream request. Canonical spelling is pinned in `fixtures/thread/` and must match `extproc/headers`.
</Accordion>
</AccordionGroup>

## Next

<CardGroup>
<Card title="Capture and derive" href="/capture-and-derive">
How `raw_turns` is appended and projected into sessions, traces, and spans.
</Card>
<Card title="Read API vs ingest" href="/read-vs-ingest">
Why `:8081` and `:8082` are different contracts and trust boundaries.
</Card>
<Card title="Ingest API" href="/ingest-api">
Envelope fields, `413` / `reject_oversize`, and `GET /openapi` on ingest.
</Card>
<Card title="Gateway capture" href="/gateway-capture">
`tapes-extproc` Envoy path that POSTs the same ingest contract.
</Card>
<Card title="Inspect and export" href="/inspect-and-export">
List the session you just captured and export JSONL.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Wrong-port capture and other operator failures.
</Card>
</CardGroup>
