# Gateway capture

> tapes-extproc Envoy adapter: env config, gRPC recv limits, ingest POST of completed turns, and RawResponseMode for the capture ratchet.

- 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

- `cli/tapes-extproc/main.go`
- `extproc/processor.go`
- `extproc/config.go`
- `extproc/dispatcher.go`
- `extproc/rawlane.go`
- `Dockerfile.extproc`
- `ingest/openapi.go`

---

---
title: "Gateway capture"
description: "tapes-extproc Envoy adapter: env config, gRPC recv limits, ingest POST of completed turns, and RawResponseMode for the capture ratchet."
---

`tapes-extproc` is a standalone gRPC sidecar that implements Envoy `envoy.service.ext_proc.v3.ExternalProcessor`. It sits on the Envoy AI Gateway request path, observes completed LLM turns, and POSTs a JSON envelope to private ingest (`POST /v1/ingest`). It is **not** a `tapes serve` subcommand: the image is built from `Dockerfile.extproc` (`cli/tapes-extproc`) so capture stays up even when the API, database, or derive worker are down.

<Warning>
Do not expose ingest through an edge gateway. Ingest trusts org identity in the envelope; `tapes-extproc` is the in-cluster writer. Point `TAPES_INGEST_URL` at the ingest base URL (cluster default `http://tapes-ingest:8090`; local `tapes serve` ingest is `:8082`).
</Warning>

## Runtime surfaces

The process binds two listeners. Version and commit come from image `ldflags`; an unstamped build logs `dev` / `HEAD`.

| Surface | Default | Protocol | Role |
| --- | --- | --- | --- |
| `TAPES_LISTEN_ADDR` | `0.0.0.0:50051` | gRPC | Envoy ext_proc `Process` RPC plus gRPC health |
| `TAPES_METRICS_ADDR` | `0.0.0.0:9090` | HTTP | Prometheus `/metrics`, `/healthz`, `/readyz` |

`/healthz` and `/readyz` always return `200` with `{"status":"ok"}` / `{"status":"ready"}`. They do **not** probe ingest. gRPC health is registered as `SERVING`.

On `SIGINT`/`SIGTERM` the gRPC server `GracefulStop`s and the HTTP server shuts down with a 5s timeout.

```mermaid
flowchart LR
  subgraph Client
    Agent[Agent or provider SDK]
  end
  subgraph Envoy["Envoy AI Gateway"]
    Filter[ext_proc filter]
    Upstream[LLM provider]
  end
  subgraph Sidecar["tapes-extproc"]
    Proc[Processor]
    Disp[Dispatcher]
  end
  subgraph Ingest["Private ingest"]
    Write["POST /v1/ingest"]
    Log[raw_turns]
  end
  Agent --> Filter
  Filter <-->|gRPC Process :50051| Proc
  Filter --> Upstream
  Proc --> Disp
  Disp -->|JSON envelope| Write
  Write --> Log
```

## Configuration

`ConfigFromEnv` reads only environment variables. Invalid integer capacities and an unrecognized `TAPES_RAW_RESPONSE_MODE` log an error and fall back to the safe default so a typo does not take the sidecar off the request path.

<ParamField body="TAPES_INGEST_URL" type="string">
Base URL of the ingest server. Default `http://tapes-ingest:8090`. The dispatcher POSTs to `{TAPES_INGEST_URL}/v1/ingest`.
</ParamField>

<ParamField body="TAPES_LISTEN_ADDR" type="string">
gRPC listen address. Default `0.0.0.0:50051`.
</ParamField>

<ParamField body="TAPES_METRICS_ADDR" type="string">
HTTP metrics/health listen address. Default `0.0.0.0:9090`.
</ParamField>

<ParamField body="TAPES_PROVIDER_MAP_FILE" type="string">
Optional YAML file mapping Envoy `x-ai-eg-selected-backend` values to provider names (`anthropic`, `openai`, …). Missing or unparsable file is a warning; the process still starts. Empty default.
</ParamField>

<ParamField body="TAPES_MAX_INFLIGHT_DISPATCHES" type="int">
Count cap on concurrent ingest POSTs. Default `100`. Non-positive or unparsable values fall back to `100`.
</ParamField>

<ParamField body="TAPES_GRPC_MAX_RECV_BYTES" type="int">
gRPC `MaxRecvMsgSize`. Default `67108864` (64 MiB). Must cover the 32 MB Anthropic Messages contract plus gRPC framing: the aigw filter buffers, so a body often arrives as one message. Bodies over this limit never reach `onRequestBody`; request size is still observed from `Content-Length` on headers.
</ParamField>

<ParamField body="TAPES_DISPATCH_BYTE_BUDGET" type="int">
Weighted semaphore on marshalled ingest payload bytes in flight. Default `268435456` (256 MiB).
</ParamField>

<ParamField body="TAPES_RAW_RESPONSE_MODE" type="string">
Response half of the envelope: `off` (default), `dual`, or `raw`. Unknown values fall back to `off`.
</ParamField>

## Request path

`Process` is a per-stream state machine: `RequestHeaders` → `RequestBody*` → `ResponseHeaders` → `ResponseBody*`. Dispatch fires once on response-body `EndOfStream` (or, on client disconnect after a `200` with buffered bytes, a salvage attempt).

```mermaid
sequenceDiagram
  participant E as Envoy
  participant P as tapes-extproc
  participant I as ingest
  E->>P: RequestHeaders
  Note over P: Resolve provider, parse X-Tapes-*, strip x-tapes-*
  P-->>E: HeadersResponse plus HeaderMutation
  E->>P: RequestBody chunks through EOS
  Note over P: Accumulate; on EOS decode request, ModeOverride if stream
  P-->>E: BodyResponse
  E->>P: ResponseHeaders :status Content-Type Content-Encoding
  E->>P: ResponseBody chunks through EOS
  alt status 200 and capturable turn
    P->>I: POST /v1/ingest JSON envelope
  else non-200, empty, ineligible, or decode/reduce fail
    P->>P: drop metric, no POST
  end
  P-->>E: BodyResponse ack
```

Forwarding never waits on ingest. Over-budget request capture stops accumulating but continues acking so Envoy still forwards the LLM call.

### Header strip

Every inbound `x-tapes-*` header is removed via `HeaderMutation` before Envoy forwards upstream. Prefix match keeps new envelope members from leaking to the provider.

### Streaming mode override

On request-body EOS, if a reducer can consume the turn and the request is `stream:true` (or the request was shed as over-budget, so `stream` cannot be parsed), the sidecar issues `ModeOverride` with `ResponseBodyMode = FULL_DUPLEX_STREAMED`. Non-streaming eligible turns stay on Envoy’s default `BUFFERED` path.

### Disconnect salvage

If the stream closes after a completed request, a `200`, and response bytes but no response EOS, the sidecar still runs `dispatchTurn`. That is the common “client closed after `message_stop`” shape under streamed ext_proc. Other incomplete streams drop as `upstream_no_response` or `client_disconnect`.

## Eligible traffic

Provider resolution prefers `x-ai-eg-selected-backend` through `TAPES_PROVIDER_MAP_FILE`, then path suffix:

| Path suffix | Provider label | Endpoint label | Reduced and posted? |
| --- | --- | --- | --- |
| `/v1/messages` | `anthropic` | `messages` | Yes — Anthropic Messages reducer |
| `/v1/responses`, `/codex/responses` | `openai` | `responses` | Yes — OpenAI Responses reducer |
| `/v1/chat/completions` | `openai` | `chat_completions` | No — `unknown_provider` |
| `/api/chat` | `ollama` | `ollama_chat` | No — `unknown_provider` |
| `/v1/messages/count_tokens` | `anthropic` | `messages_count_tokens` | No — `non_turn_request` |

Only **POST** (or a missing `:method`) on a turn path is capturable. Upstream status must be exactly **200** — not “any 2xx”. A missing `:status` drops as `missing_status`.

Reducers consume decoded textual JSON or SSE. Request and response `Content-Encoding` are undone with `capture.DecodeContentEncoding` (the same function ingest uses). A truncated compressed **request** is refused (`request_decode`). A truncated compressed **response** is salvaged and still reduced.

Eligible reducers stamp `Usage.TotalDurationNs` from the sidecar’s wall clock before dispatch. That stamp only exists on this path; ingest persists it verbatim.

## Session envelope

If any `x-tapes-*` header is present (including `x-tapes-agent-name`), the POST includes a `session` object. Otherwise the block is omitted.

| Inbound header | Envelope field |
| --- | --- |
| `x-tapes-harness-id` | `harness_id` (missing/empty → `"unknown"`) |
| `x-tapes-harness-session-id` | `harness_session_id` |
| `x-tapes-harness-version` | `harness_version` |
| `x-tapes-cwd` | `cwd` |
| `x-tapes-session-name` | `name` (percent-decoded) |
| `x-tapes-parent-harness-session-id` | `parent_harness_session_id` |
| `x-tapes-harness-metadata` | `harness_metadata` (base64url JSON; malformed drops the field only) |
| `x-paper-auth-org-id` | `org_id` |
| `x-paper-auth-subject` | `auth_subject` |

`x-paper-auth-org-id` and `x-paper-auth-subject` are **not** part of the strip set. They are meant to be populated by the gateway from validated JWT claims (`claim_to_headers`). Clients must not be allowed to set them; if the gateway does not inject them, both fields are empty strings.

`meta.thread_id` is resolved harness-neutrally:

- Claude Code: first present `x-claude-code-agent-id`
- Codex: `thread-id` only when it differs from `session-id` (root turns keep `thread_id` empty)

`x-request-id` is copied into `meta.request_id`. If absent, the sidecar synthesizes `extproc-` plus 8 hex bytes so logs and ingest dedup still have a pivot.

## Ingest POST

`Dispatcher` marshals `TurnEnvelope` and POSTs asynchronously.

<RequestExample>
```http
POST {TAPES_INGEST_URL}/v1/ingest HTTP/1.1
Content-Type: application/json
```
</RequestExample>

```json
{
  "provider": "anthropic",
  "agent_name": "claude",
  "request": {},
  "response": {},
  "raw_response": null,
  "raw_response_encoding": "",
  "raw_response_withheld": false,
  "meta": {
    "request_id": "…",
    "content_type": "text/event-stream",
    "thread_id": "",
    "method": "POST",
    "path": "/v1/messages",
    "endpoint": "messages",
    "model": "claude-sonnet-4-5",
    "model_family": "claude",
    "stream": "true",
    "content_encoding": "gzip",
    "upstream_status": 200,
    "upstream_status_class": "2xx",
    "request_bytes": 1234,
    "response_bytes": 5678,
    "elapsed_seconds": 1.23
  },
  "session": {
    "org_id": "",
    "auth_subject": "",
    "harness_id": "claude",
    "harness_session_id": "…"
  }
}
```

`request` is decoded provider JSON. `response` is the adapter’s reduced `llm.ChatResponse` unless raw-only omits it. `raw_response` is the **wire** body (still compressed if the upstream sent gzip/zstd), JSON-encoded as standard padded base64 — not the decoded reducer input.

| Dispatch rule | Behavior |
| --- | --- |
| Count semaphore full | Drop `sem_full`, no POST |
| Byte budget cannot admit marshalled size | Drop `sem_full` |
| HTTP client timeout | 30s per attempt |
| Attempts | 3, backoff 500ms then 1s |
| `200` or `202` | Accept |
| 5xx or network error | Retry; then `ingest_timeout` |
| Other 4xx (including 413) | Terminal `ingest_reject` |
| Context cancel during retry | `ingest_timeout` |

Ingest treats `meta.request_id` as the idempotency key for a retried POST of the same turn.

## RawResponseMode

The mode is the capture-path half of the ratchet that moves reduction from the adapter into ingest so every writer uses `pkg/capture`.

| Mode | Envelope `response` | Envelope `raw_response` | Typical stored fidelity |
| --- | --- | --- | --- |
| `off` | Adapter reduction | Absent | reduced |
| `dual` | Adapter reduction | Wire bytes | raw (ingest keeps the adapter reduction) |
| `raw` | Omitted (ingest reduces) | Wire bytes | raw |

Roll out `off` → `dual` → prove with `tapes raw equivalence` → `raw`. Do not switch a fleet to `raw` while its ingest image predates the shared `DecodeContentEncoding` path (zstd / stacked encodings): dual is safe in either roll order; raw-only bytes an older ingest cannot decode become unreducible archive.

### When bytes are withheld

extproc does **not** pre-drop at ingest’s 8 MiB `MaxRawResponseBytes` storage cap. Bytes between that cap and the transport budget are attached on purpose so ingest can drop-and-mark (`raw_response_dropped` / fidelity degraded).

It **does** refuse to attach past the ingest HTTP body limit (`MaxIngestBodyBytes` = 32 MiB decoded request + base64 of 8 MiB raw + 4 MiB reserve ≈ 46.67 MiB):

1. Pre-dispatch estimate (`rawResponseFits`) with a 1 MiB envelope reserve. Failure sets `raw_response_withheld` and skip reason `transport_budget`.
2. Post-marshal `enforceBodyLimit`. If the real JSON exceeds the body limit and raw bytes are present, strip them, restore the reduction on a raw-only envelope, set `raw_response_withheld`, and skip reason `oversize_stripped`.

A turn that lands reduced beats a 413 that loses request, reduction, and session. `mode=off` never sets `raw_response_withheld` — those adapters captured nothing to withhold.

If `mode=raw` but the body was not decoded with ingest’s decoder, the sidecar keeps the reduction (`encoding_not_decodable`) instead of shipping unreducible archive. On the production path that branch does not fire: a decode failure already dropped the turn as `response_decode`.

Request accumulation stops at `requestCaptureBudget` (`MaxIngestBodyBytes − 1 MiB`). Those turns drop as `request_over_budget` before marshal.

## Drop reasons

Capture-policy reasons are the shared `pkg/capture` vocabulary (same strings as other writers). Transport reasons are this adapter’s own.

| Reason | When |
| --- | --- |
| `upstream_status` | Response EOS and status ≠ 200 |
| `non_turn_request` | Path/method is not a POST chat turn |
| `request_decode` | Request `Content-Encoding` failed or truncated |
| `empty_response` | Response phase completed with zero body bytes |
| `unknown_provider` | No reducer for `(provider, endpoint)` |
| `response_decode` | Response encoding could not be decoded |
| `reducer_error` | Reducer returned an error |
| `request_over_budget` | Request exceeded the capture budget |
| `marshal_error` | Envelope JSON marshal failed |
| `ingest_reject` | Ingest 4xx |
| `ingest_timeout` | Network/5xx exhausted or context cancelled |
| `sem_full` | Count or byte admission refused |
| `client_disconnect` | Stream torn down mid-turn |
| `upstream_no_response` | Request EOS, no response bytes |
| `missing_status` | Response body without `:status` |

Empty reducer output (missing role, empty content, missing block type) is still dispatched and counted on `tapes_extproc_reducer_empty_total` so ingest validation — not a silent drop — is the authority.

## Observability

JSON logs on stdout. Useful Prometheus series on `/metrics`:

| Metric | Notes |
| --- | --- |
| `tapes_extproc_turns_captured_total` | Accepted POSTs |
| `tapes_extproc_turns_dropped_total` | `{provider,reason}` — all reasons pre-created at 0 |
| `tapes_extproc_turns_terminal_total` | Bounded labels only (`endpoint`, `stream`, `model_family`, `upstream_status_class`) |
| `tapes_extproc_turns_large_total` | Response body ≥ 4 MiB; **not** a drop |
| `tapes_extproc_body_bytes` | Request/response histograms, 256 B–64 MiB |
| `tapes_extproc_raw_response_attached_total` | `shape=dual` or `raw_only` after the marshal backstop |
| `tapes_extproc_raw_response_skipped_total` | `transport_budget` / `oversize_stripped` |
| `tapes_extproc_raw_response_fallback_total` | Raw-only kept its reduction |
| `tapes_extproc_response_decode_salvaged_total` | Truncated gzip/zstd recovered |

`thread_id`, raw paths, and model names stay in structured logs, not metric labels.

## Image

`Dockerfile.extproc` builds `./cli/tapes-extproc` with `CGO_ENABLED=0` and `GOEXPERIMENT=jsonv2` (required: shared packages compile under jsonv2; building without it decodes differently from ingest). Runtime image is `gcr.io/distroless/static:nonroot`, user `65532:65532`, `EXPOSE 50051 9090`, entrypoint `/tapes-extproc`. Dagger publishes `<registry>/tapes-extproc:<tag>`.

:::files
```text
cli/tapes-extproc/main.go   # process: env, gRPC+HTTP, signals
extproc/config.go           # ConfigFromEnv, recv/dispatch defaults
extproc/processor.go        # ext_proc state machine, reducers
extproc/dispatcher.go       # POST /v1/ingest, retry, body-limit strip
extproc/rawlane.go          # RawResponseMode + attach/skip/fallback
extproc/headers/            # X-Tapes-*, thread-id, PaperAuth
Dockerfile.extproc
```
:::

## Operations notes

<Steps>
<Step title="Run ingest as a private writer">
In a split stack: `tapes serve ingest --postgres "$TAPES_STORAGE_POSTGRES_DSN"`. Do not send capture at the read API (`:8081`).
</Step>
<Step title="Point the sidecar at ingest">
Set `TAPES_INGEST_URL` to the ingest **base** URL. Confirm the process log line `ingest_url` and `raw_response_mode` on startup.
</Step>
<Step title="Attach Envoy ext_proc">
Point the Envoy ext_proc filter at `TAPES_LISTEN_ADDR` (`:50051`). This repository does not ship the Envoy bootstrap; the sidecar only implements the processor service.
</Step>
<Step title="Verify">
`GET :9090/readyz` → `{"status":"ready"}`. A captured 200 Messages/Responses turn increments `tapes_extproc_turns_captured_total` and appears on ingest as `202`. A 413 from ingest is `ingest_reject` unless the raw-lane strip already kept the turn under the body limit.
</Step>
<Step title="Ratchet raw capture">
Leave `TAPES_RAW_RESPONSE_MODE=off` until dual traffic is stored, then prove with `tapes raw equivalence` before `raw`.
</Step>
</Steps>

<AccordionGroup>
<Accordion title="Turns never land">
Check `tapes_extproc_turns_dropped_total`. `unknown_provider` means Chat Completions, Ollama, or an unmapped backend. `sem_full` means raise `TAPES_MAX_INFLIGHT_DISPATCHES` / `TAPES_DISPATCH_BYTE_BUDGET` or ingest is too slow. `ingest_timeout` means ingest is unreachable at `TAPES_INGEST_URL`.
</Accordion>
<Accordion title="413 / reject_oversize">
Ingest Fiber `BodyLimit` is `MaxIngestBodyBytes`. Dual/raw should strip verbatim bytes first. If the reduced envelope is still oversize, the turn is sent as-is and ingest rejects it — same as pre-raw-lane behavior.
</Accordion>
<Accordion title="gRPC RESOURCE_EXHAUSTED on large requests">
Increase `TAPES_GRPC_MAX_RECV_BYTES` (default 64 MiB). Headers still arrive; bodies over the recv limit never do.
</Accordion>
<Accordion title="Empty or gzip-looking reductions">
Confirm response `Content-Encoding` decode metrics. `response_decode` is a distinct drop; salvaged gzip logs `message_stop_seen`.
</Accordion>
</AccordionGroup>

## Next

<CardGroup>
<Card title="Read API vs ingest" href="/read-vs-ingest">
:8081 is the public read surface; :8082 / in-cluster ingest is the private write path this sidecar calls.
</Card>
<Card title="Ingest API" href="/ingest-api">
`POST /v1/ingest` contract, body cap, 413 envelope, and idempotency on `meta.request_id`.
</Card>
<Card title="Prove the capture ratchet" href="/capture-ratchet">
`off` / `dual` / `raw`, `tapes raw equivalence` classes, and excluded stamp fields.
</Card>
<Card title="Capture and derive" href="/capture-and-derive">
How a posted envelope becomes `raw_turns` and then sessions, traces, and spans.
</Card>
<Card title="Split the stack" href="/split-the-stack">
Run ingest as its own process so the gateway writer has an independent failure domain.
</Card>
</CardGroup>
