# Attribute cost per agent step

See dollars per trace, per agent, and per step by putting x-tt-* identity headers on the wire and reading them back with top.

Source: https://tokentriage.com/docs/recipes/attribute-cost-per-agent-step

---
import Callout from '../../../components/Callout.astro';
import NextSteps from '../../../components/NextSteps.astro';
import CodeTabs from '../../../components/CodeTabs.astro';

Get **exact dollars per trace, per agent, and per step** for a multi-step agent by stamping
explicit `x-tt-*` identity headers on each outbound LLM call, then reading the ledger back
with `tokentriage top`.

TokenTriage attributes cost by **trace identity**, and identity comes *exclusively* from
explicit request headers — never a timing- or prefix-based guess. A request with no
`x-tt-trace-id` is still fully costed as a standalone row; it just isn't grouped into a trace.
That is the trade: a narrow ledger over a wrong one.

## Prerequisites

- A running proxy. The examples below assume the deterministic default data-plane port `8787`:

  ```console
  $ tokentriage run --simple openai/gpt-4o-mini --complex openai/gpt-4o
  ```

- Your agent controls its own HTTP client (LangGraph, CrewAI, LlamaIndex, or a hand-written
  loop) so it can set outbound headers per request. Claude Code cannot — see the note at the end.

## The identity headers

These four headers carry trace identity. The names and casing are the wire contract exactly as
`internal/trace/extract.go` reads them:

| Header | Sets | Cadence |
|---|---|---|
| `x-tt-trace-id` | Groups requests into one agent trace. | Once per top-level run. |
| `x-tt-step-id` | Identifies the step within the trace — enables the per-step tree. | Fresh per LLM call. |
| `x-tt-parent-id` | The parent step id — draws the sub-agent fan-out edge. | On delegation only. |
| `x-tt-agent` | Logical agent / sub-agent name (e.g. `planner`, `coder`). | Per role. |

One optional header refines the picture: `x-tt-step-class` — an explicit step class, one of
`user_turn`, `tool_loop`, or `provider_state`. When absent, the proxy derives a structural
guess and records `step_class_source: derived`; the guess feeds routing but never trace
*identity*.

All `x-tt-*` headers are **stripped before the request is forwarded upstream** — the provider
never sees them.

<Callout type="note">
The one-line recipe: **`x-tt-trace-id` per run, `x-tt-agent` per role, `x-tt-step-id` per call,
`x-tt-parent-id` on delegation.**
</Callout>

## Step 1 — Stamp the headers on each call

Point your client's `base_url` at the proxy and set a stable trace id for the whole run, plus a
fresh step id (and the parent edge for sub-agents) on every call.

**Raw headers (any HTTP client)**

<CodeTabs>
<Fragment slot="python">
```python
import os, uuid, urllib.request, json

BASE = os.environ.get("TOKENTRIAGE_BASE_URL", "http://127.0.0.1:8787").rstrip("/")
TRACE_ID = f"run-{uuid.uuid4().hex[:12]}"   # one stable id for the whole run

def call_llm(messages, *, agent, parent_step=None, step_class="user_turn"):
    step_id = f"step-{uuid.uuid4().hex[:12]}"
    headers = {
        "Content-Type": "application/json",
        "x-tt-trace-id": TRACE_ID,
        "x-tt-step-id": step_id,
        "x-tt-agent": agent,             # "planner" | "coder" | "Task:reviewer"
        "x-tt-step-class": step_class,   # user_turn | tool_loop | provider_state
    }
    if parent_step:
        headers["x-tt-parent-id"] = parent_step   # fan-out edge for the sub-agent
    payload = json.dumps({"model": "gpt-4o-mini", "messages": messages}).encode()
    req = urllib.request.Request(
        f"{BASE}/v1/chat/completions", data=payload, headers=headers, method="POST"
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        return step_id, json.loads(resp.read())

# A two-step agent: a planner turn, then a coder sub-agent linked to the planner step.
planner_step, _ = call_llm(
    [{"role": "user", "content": "Plan a haiku about ledgers."}], agent="planner")
call_llm(
    [{"role": "user", "content": "Write the haiku."}],
    agent="coder", parent_step=planner_step, step_class="tool_loop")
```
</Fragment>
<Fragment slot="typescript">
```typescript
import { randomUUID } from "node:crypto";

const short = () => randomUUID().replace(/-/g, "").slice(0, 12);
const BASE = (process.env.TOKENTRIAGE_BASE_URL ?? "http://127.0.0.1:8787").replace(/\/+$/, "");
const TRACE_ID = `run-${short()}`;   // one stable id for the whole run

async function callLLM(
  messages: unknown[],
  { agent, parentStep, stepClass = "user_turn" }:
    { agent: string; parentStep?: string; stepClass?: string },
): Promise<[string, unknown]> {
  const stepId = `step-${short()}`;
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    "x-tt-trace-id": TRACE_ID,
    "x-tt-step-id": stepId,
    "x-tt-agent": agent,             // "planner" | "coder" | "Task:reviewer"
    "x-tt-step-class": stepClass,    // user_turn | tool_loop | provider_state
  };
  if (parentStep) headers["x-tt-parent-id"] = parentStep;   // fan-out edge for the sub-agent
  const resp = await fetch(`${BASE}/v1/chat/completions`, {
    method: "POST",
    headers,
    body: JSON.stringify({ model: "gpt-4o-mini", messages }),
  });
  return [stepId, await resp.json()];
}

// A two-step agent: a planner turn, then a coder sub-agent linked to the planner step.
const [plannerStep] = await callLLM(
  [{ role: "user", content: "Plan a haiku about ledgers." }], { agent: "planner" });
await callLLM(
  [{ role: "user", content: "Write the haiku." }],
  { agent: "coder", parentStep: plannerStep, stepClass: "tool_loop" });
```
</Fragment>
</CodeTabs>

**SDK (mints and propagates the ids for you)**

<CodeTabs>
<Fragment slot="python">
```python
import tokentriage as tt

tt.init(base_url="http://127.0.0.1:8787")

with tt.trace(user="u-42", tenant="acme", feature="chat"):
    with tt.step(agent="planner", step_class="user_turn") as planner:
        resp = post(tt.headers(), plan_messages)   # your HTTP call
        tt.receipt(resp)
    with tt.step(agent="coder", step_class="tool_loop", parent=planner.step_id):
        resp = post(tt.headers(), code_messages)
```
</Fragment>
<Fragment slot="typescript">
```typescript
import * as tt from "tokentriage";

tt.init({ baseUrl: "http://127.0.0.1:8787" });

await tt.trace({ user: "u-42", tenant: "acme", feature: "chat" }, async () => {
  let plannerId: string | undefined;
  await tt.step({ agent: "planner", stepClass: "user_turn" }, async () => {
    plannerId = tt.currentStep()?.stepId;
    const resp = await post(tt.headers(), planMessages);   // your HTTP call
    tt.receipt(resp);
  });
  await tt.step({ agent: "coder", stepClass: "tool_loop", parent: plannerId }, async () => {
    const resp = await post(tt.headers(), codeMessages);
  });
});
```
</Fragment>
</CodeTabs>

The SDK emits the byte-identical wire — same header names, same parent edge — only the minted
id *values* differ from hand-picked ones. See the [Python SDK](/docs/sdks/python) reference for
`trace()` / `step()` / `headers()`.

<Callout type="tip">
For **LangGraph / LangChain** pass `default_headers` (or `extra_headers` per call) on the
`ChatAnthropic` / `ChatOpenAI` client and point `base_url` at the proxy: set `x-tt-trace-id` per
graph invocation and `x-tt-agent` per node. For **CrewAI** set the LLM `base_url` to the proxy
and inject `x-tt-agent` = the crew member's role and `x-tt-trace-id` = the crew run id via the
client's default headers; add `x-tt-parent-id` when a task delegates.
</Callout>

## Step 2 — Read back the per-step tree

You already have `TRACE_ID` from the run above; feed it to `tokentriage top --trace`. Steps
are linked by `x-tt-parent-id` → `x-tt-step-id` and rendered as an indented tree with a
per-step cost column:

```console
$ tokentriage top --trace run-a1b2c3d4e5f6
```

You should see (illustrative):

```
trace run-a1b2c3d4e5f6  (2 steps)
STEP                CLASS      AGENT    MODEL         COST       IN    OUT  CACHE-R
step-9f8e7d6c5b4a   user_turn  planner  gpt-4o-mini   $0.000180  120   240  0
  step-1a2b3c4d5e6f  tool_loop  coder    gpt-4o        $0.004500  310   520  0
TOTAL                                                  $0.004680  430   760  0
```

The child step is indented under its parent because the `x-tt-parent-id` edge was on the wire —
not inferred. A step whose price could not be resolved shows `-` in the `COST` column and is
excluded from `TOTAL` (a `$0` there would falsely claim the step was free).

## Step 3 — Roll up by agent or step class

To see spend split across roles or step classes across *all* traces, use `--by`:

```console
$ tokentriage top --by agent
```

```
AGENT    REQS  INPUT  OUTPUT  CACHE-R  CACHE-W  REASON  CACHE-SAV  COST       P50        P90        P99
planner  8     960    1920    0        0        0       0.0%       $0.001440  $0.000180  $0.000180  $0.000180
coder    8     2480   4160    0        0        0       0.0%       $0.036000  $0.004500  $0.004500  $0.004500
TOTAL    16    3440   6080    0        0        0       0.0%       $0.037440
```

The accepted `--by` dimensions are `trace`, `agent`, `model`, `step-class`, and `tool`. So
`tokentriage top --by step-class` breaks spend down by `user_turn` / `tool_loop` /
`provider_state`, and `--by trace` lists every run. Add `--since 24h` (also `30d`, `2w`, or an
RFC3339 timestamp) to window the rollup.

<Callout type="note">
Both `top --trace` and `top --by` read the decision log at `~/.tokentriage/decisions.jsonl` by
default; pass `--log <path>` to point at another file or directory.
</Callout>

## Claude Code: per-session, not per-step

`tokentriage wrap` runs a child command with `ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` pointed at
the proxy and a fresh `x-tt-trace-id` injected via `ANTHROPIC_CUSTOM_HEADERS`:

```console
$ tokentriage wrap --agent claude -- claude
tokentriage wrap: base=http://127.0.0.1:8787 trace=tt_… agent="claude"
```

Because `ANTHROPIC_CUSTOM_HEADERS` attaches only **static** headers, every call in the session
shares one trace id and (with `--agent`) one agent name — so you get honest per-*session*
grouping, not per-step. Claude Code exposes no per-subagent wire identity, so per-step
attribution is out of scope there. Frameworks that own the HTTP client (Step 1) get the full
per-step tree.

<Callout type="warning">
`x-tt-step-class` informs routing but never overrides who a row belongs to, and setting
`x-tt-tier` forces a tier without explaining *why* — the signals and rule that would have fired
are still recorded. Identity always comes from the four headers above, never from content.
</Callout>

<NextSteps items={[
  { title: 'Observability & tracing', href: '/docs/build/observability', description: 'The full x-tt-* contract, the Live feed, business dimensions, and OTel export.' },
  { title: 'Header reference', href: '/docs/reference/headers', description: 'Every request and response header, its casing, and its strip-before-upstream rule.' },
  { title: 'Python SDK', href: '/docs/sdks/python', description: 'trace() / step() / headers() — mint and propagate identity across async boundaries.' },
  { title: "OpenTelemetry export", href: "/docs/build/observability-otlp", description: "Connect gateway evidence to the tracing system you already operate." },
]} />
