# Request and response capture

By default no prompt or completion text reaches disk. What changes when you turn capture on, and who can then read the result.

Source: https://tokentriage.com/docs/build/dashboard-capture

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';
import Screenshot from '../../../components/Screenshot.astro';

Every row in your ledger records a SHA-256 of the prompt and nothing else of its content. That
is the shipped default: you get counts, costs and routing decisions, and the text of what
anyone asked or was told stays out of your storage.

You can change that. Content capture stores request and response bodies so a record on the
dashboard can show you what a request actually said. It is off until you turn it on, and this
page is what you should read before you do.

## What the default gives you

Without capture, a decision-log row holds `prompt_sha256`, a hash, alongside the metadata that
makes the dashboard work: model, tier, tokens, cost, latency, the routing decision and
any business dimensions you tagged. That is enough to attribute spend, compare tiers, explain
a decision and reconcile an invoice.

What it is not enough for is debugging content. You cannot read the prompt that produced a bad
answer, and you cannot tell two requests apart beyond their hashes matching or not.

<Callout type="key">
Hash-only is the default, and it is a real privacy property rather than a setting nobody got
around to changing. No prompt text and no completion text is written by a deployment that has
not opted in.
</Callout>

## What turning it on does

```yaml
dashboard:
  capture:
    request: true          # default: false — stores prompt text
    response: true         # default: false — stores completion text
    ttl: 720h              # default: 720h (30 days); 0 = keep until retention sweeps
    path: /var/lib/tokentriage/content
```

The two sides are independent. Capturing requests stores the system prompt and messages;
capturing responses stores the completion. Turn on one, the other, or both.

Bodies live in a sidecar store at `capture.path`, outside the append-only ledger. That
separation is what makes them deletable: a captured body can be swept without touching the row
it belongs to, so removing content leaves no hole in your cost history.

The dashboard states the consequence on the surface rather than in a tooltip:

<Screenshot
  stem="dashboard-settings-capture"
  dark={true}
  url="localhost:9090/ui/settings"
  alt="The content-capture panel in Settings. Request and response each show a CAPTURING badge with a sentence describing what is stored, and a bold red banner reads: capture is ON, prompt and or completion text is being written to disk for matching requests, bodies live in a sidecar store, never in the append-only ledger, and are pruned by the capture TTL. Below, the retention TTL reads 720h and the store path is shown."
  caption="The content-capture panel with both sides on. The consequence is stated in bold whenever either side is capturing (real dashboard, demo mode)."
/>

## Four properties to know before you enable it

**Capture fails open.** If writing a body fails for any reason, the request is unaffected, the
ledger row is still written, and the row's capture flag is false. Storing content cannot become a way for your gateway to break traffic.

**Streaming responses are not capturable.** A streamed completion is relayed byte-for-byte and
never buffered, which is what makes the proxy transparent, and it also means there is no
complete body to store. If your traffic is mostly streaming, response capture will get you
much less than you expect. The surface discloses this.

**Bodies are not inlined into the API.** A record response carries a *reference* to
captured content rather than the content itself. Reading the body is a separate, authenticated request
against a content route that is proof against path traversal, and it requires the
`content:read` scope — one of the seven privileged operations in
[roles and permissions](/docs/build/dashboard-roles).

**The sweeper removes content only.** An audited TTL sweep deletes captured bodies whose
lifetime has elapsed. It leaves the ledger row alone, so a swept request keeps its cost, its
decision and its hash, losing only its text.

## Deciding whether to turn it on

Three questions, in this order.

**Do you have a debugging problem capture would solve?** The common one is a routing decision
that looks wrong and cannot be explained from metadata. If your open questions are all about
cost or attribution, capture will not answer them.

**What is in your prompts?** They are your users' text. Turning capture on makes your
TokenTriage volume part of whatever regime governs that text, and the store is a new place it
lives. This is the question worth asking someone else in your organisation.

**Who will be able to read it?** Anyone with the `content:read` scope on the admin plane.
Check that list before, not after.

If you turn it on, prefer a short `ttl` over the default. Thirty days of prompt text is a lot
of prompt text, and the sweeper only removes what the lifetime says it may.

<Details summary="How capture relates to redaction and to the ledger's other content controls">

Three different mechanisms touch content, and they are simple to confuse.

**`ledger.redact.prompt`** decides what the ledger row itself carries about the prompt.
The shipped setting is `hash`.

**`dashboard.capture`** decides whether full bodies are written to the sidecar store. This
page.

**`retention`** rotates and prunes the decision log itself, leaving the active file alone.
[The decision ledger on disk](/docs/operate/ledger-lifecycle) owns it.

They compose rather than override: capture off with redaction hashing is the default, and each
can be changed without the other.

</Details>

## Turning it off again

Set both sides to `false` and restart. That stops new bodies being written; it does not delete
the ones already there. To remove those, delete the store at `capture.path`, or set a short
`ttl` and let the sweeper do it — the sweep is audited, which a manual delete is not.

Nothing about the ledger changes either way, because the bodies were not in it.

## What can go wrong

**Capture is on and records show no content.** Most likely the traffic is streaming. Check a
non-streaming request before suspecting the setting.

**The panel says capturing and you did not turn it on.** `tokentriage demo` enables both
sides, because its traffic is synthetic and there is nothing to protect. A demo instance is
not evidence about your own config.

**The store grows faster than expected.** It holds bodies, so it scales with prompt size rather
than request count. Shorten the `ttl`.

**Somebody can read prompts who should not.** That is a role question rather than a capture
question. `content:read` is privileged; audit who holds it.

<NextSteps items={[
  {title: "Roles and permissions", href: "/docs/build/dashboard-roles", description: "The content:read scope, and the other six privileged operations."},
  {title: "The decision ledger on disk", href: "/docs/operate/ledger-lifecycle", description: "What the ledger itself holds, how it rotates, and what retention prunes."},
  {title: "Decision ledger fields", href: "/docs/reference/ledger-fields", description: "Every field on a row, including the hash that capture supplements."},
  {title: "Harden a deployment", href: "/docs/operate/hardening", description: "The wider stance for an admin plane that can now serve request text."}
]} />
