Runs and attribution

A run is one unit of work you care about: a nightly backfill, a pipeline task, one agent session. Tag it once at dispatch time and the proxy answers "what did that cost, and which agent spent it" without any code changes.

What a run is

The hierarchy is run > agent > thread > request. Every request the proxy handles already has a trace id, a model, a token count and a cost. Attribution adds one thing: a label attached at dispatch time. The rollup is then just a group-by. That is why this works with no SDK, no instrumentation library and no code inside your agent: you tag the process, the proxy does the arithmetic.

A run opens on its first request and closes on X-RelayPlane-Run-End: true, on POST /v1/runs/<id>/end, when a wrapped command exits, or after the idle timeout. A request for a closed run reopens it. Nothing is ever rejected for lifecycle reasons, and attribution never blocks a request.

Zero code: wrap the command

relayplane run wraps any command as one attributed run. Everything before the bare -- is a flag for the wrapper, everything after is the command to run. The separator is mandatory.

1relayplane run --label nightly-backfill -- ./orchestrate.sh
2
3# Optional flags, all before the --
4relayplane run --label nightly-backfill --tag repo:api --tag env:prod --cap 12 -- ./orchestrate.sh
5relayplane run --agent researcher --parent nightly-20260905-a1b2c3 -- ./sub-step.sh
6relayplane run --json -- ./orchestrate.sh # machine readable rollup on stdout

The wrapper sets ANTHROPIC_CUSTOM_HEADERS, RELAYPLANE_RUN_ID and RELAYPLANE_RUN_HEADERS in the child environment, and sets ANTHROPIC_BASE_URL to the proxy only when you have not set one yourself. stdout belongs entirely to your command; the wrapper writes to stderr, so piping still works. The exit code is your command's exit code.

It prints a start banner, then the rollup when the command exits. Costs below are notional list price for the traffic, which is what it would have cost at published per-token rates, never an invoice:

1▶ run nightly-backfill-20260905-a1b2c3 dashboard http://localhost:4100/dashboard#run=nightly-backfill-20260905-a1b2c3
2
3Run nightly-backfill-20260905-a1b2c3 (nightly-backfill) completed 18m 42s
4 cost $6.41 notional (all-opus baseline $19.02) requests 214 retries $1.10 (17%) band in [4.00, 9.00]
5 429s 0 model drift 3
6 agents: coder $3.88 (121 req, claude-sonnet-4-6 x118, claude-haiku-4-5 x3) | architect $1.94 (34 req, claude-opus-4-6 x34) | verifier $0.59 (59 req, claude-haiku-4-5 x59)
7 dashboard http://localhost:4100/dashboard#run=nightly-backfill-20260905-a1b2c3
Every dollar figure the proxy prints is notional list price. It is the counterfactual cost of the traffic at published rates, so it is comparable run to run, but nobody bills you from it and flat-rate accounts pay list price nowhere.

Claude Code

Claude Code forwards ANTHROPIC_CUSTOM_HEADERS to the proxy byte for byte, so a project-level .claude/settings.json is enough to attribute every session in that repo:

1{
2 "env": {
3 "ANTHROPIC_BASE_URL": "http://localhost:4100",
4 "ANTHROPIC_CUSTOM_HEADERS": "X-RelayPlane-Run: my-run\nX-RelayPlane-Agent: coder"
5 }
6}

The header lines are separated by a real newline, which is why the JSON above escapes it as \n.

You get attribution for Claude Code with no setup at all. Every request Claude Code makes carries x-claude-code-session-id, so the proxy opens an inferred run named cc-<session> for interactive sessions and for claude -p alike. The headers above only matter when you want your own names, your own grouping, or bands and caps.

SDK users

The Anthropic and OpenAI SDKs do not read ANTHROPIC_CUSTOM_HEADERS. They do take default headers, and relayplane run exports the same header set as JSON in RELAYPLANE_RUN_HEADERS, so one line wires the wrapper to the SDK:

1import Anthropic from '@anthropic-ai/sdk'
2
3const client = new Anthropic({
4 baseURL: 'http://localhost:4100',
5 defaultHeaders: JSON.parse(process.env.RELAYPLANE_RUN_HEADERS ?? '{}'),
6})
1import OpenAI from 'openai'
2
3const client = new OpenAI({
4 baseURL: 'http://localhost:4100/v1',
5 defaultHeaders: JSON.parse(process.env.RELAYPLANE_RUN_HEADERS ?? '{}'),
6})

Nested orchestrators can read RELAYPLANE_RUN_ID and mint a child id as `${process.env.RELAYPLANE_RUN_ID}/researcher-2` for each sub-agent they spawn.

Request headers

All header names are case-insensitive. Invalid values are ignored rather than rejected: a bad run id falls back to inference and is logged, it never fails your request.

HeaderMeaningValidation
X-RelayPlane-RunRun id. A / nests it under a parent.Charset [\w\-.:@/], 128 chars max, no empty segments, nesting depth capped at 8. Wins over every inferred source.
X-RelayPlane-AgentAgent label inside the run, e.g. coder.Printable ASCII, 64 chars max. Alias: x-agent-id.
X-RelayPlane-Parent-RunExplicit parent, for ids that do not use /.Same charset and length as the run id.
X-RelayPlane-Run-LabelHuman name, e.g. nightly-backfill. Bands key off this.Printable ASCII, 80 chars max.
X-RelayPlane-TagsFree-form k:v,k:v pairs, LiteLLM format.Up to 10 pairs, 64 chars per side. Malformed pairs are skipped.
X-RelayPlane-AttemptRetry attempt number, 1-based.Integer 1 to 999. Anything else is ignored and attempt stays 1.
X-RelayPlane-Run-Cap-UsdPer-run hard cap, honored on the request that opens the run.Finite float greater than 0.
X-RelayPlane-Run-EndCloses the run after this request.Boolean: 1, true, yes or on.

Response headers

Every proxied response carries these four, on the Anthropic-native path and the OpenAI-compatible path, streaming and non-streaming. They are listed in Access-Control-Expose-Headers, so browser clients can read them.

HeaderValue
X-RelayPlane-Run-IdThe run this request landed in, whether you named it or the proxy inferred it.
X-RelayPlane-Run-Sourceheader, inferred_cc or inferred_gap. Which rung of the degradation ladder produced the id.
X-RelayPlane-Run-Cost-UsdRun cost so far, 6 decimals, notional list price. On streaming responses headers are written before the body, so this is the cost before the current request rather than including it.
X-RelayPlane-Run-Bandnone, under, in or over against the expected-cost band for this run's label.

Nesting

A / in the run id nests it. nightly-20260905-a1b2c3/researcher-2 is a child of nightly-20260905-a1b2c3, so the parent answers "what did the whole job cost across every sub-agent" and the child answers "what did this step cost". Depth is capped at 8; deeper ids keep the trailing segments, because the tail is the interesting part.

If your ids already exist and do not use /, send X-RelayPlane-Parent-Run instead. Note that x-parent-trace-id is a different concept and is not an alias for it.

1# The orchestrator opens the parent
2relayplane run --label nightly-backfill -- ./orchestrate.sh
3
4# Inside orchestrate.sh, each sub-agent nests under it
5relayplane run --id "$RELAYPLANE_RUN_ID/researcher-2" --agent researcher -- ./research.sh

Retries

Retry spend is tracked separately from first-attempt spend, per run and per agent, so the rollup can say how much of a run was work and how much was rework. If your orchestrator knows its attempt number, send X-RelayPlane-Attempt and the answer is exact.

Without that header the proxy infers a retry from three signals:

  • after_error, the previous request on the same run, agent and thread failed within the last 120 seconds.
  • after_429, the previous request on that thread was a 429 or 529 within the last 120 seconds.
  • same_prompt, the same thread resent an identical last user message within 120 seconds. Normal multi-turn conversation does not trip this, because the last user message changes every turn.

An explicit header records the reason as header instead. The rollup line reads like retries $1.10 (17%), in notional list price.

Expected-cost bands

A band is what a given workflow normally costs, split by cache state, so you find out when today's run is not like the others. Bands key off the run label, with "*" as a global fallback. A run is warm when cache reads are at least half its input tokens, otherwise cold.

Configure them in ~/.relayplane/config.json. All amounts are notional list price in USD:

1{
2 "attribution": {
3 "bands": {
4 "nightly-backfill": { "cold": [4, 9], "warm": [1.5, 3] }
5 }
6 }
7}

You do not have to guess. Run the workflow five times, then ask the proxy what it observed and apply the suggestion:

1relayplane runs band nightly-backfill
2relayplane runs band nightly-backfill --apply

The suggestion is the p25 to p75 range of completed runs with that label over the last 30 days, per cache state, and it only appears once there are at least five samples. --apply writes attribution.bands.<label> through the proxy's config endpoint. Nothing is ever applied automatically. Crossing the high edge fires run.over_band once; closing below the low edge fires run.under_band, which usually means a step was skipped rather than that you saved money.

Per-run caps

A per-run cap stops one runaway job without stopping everything else on the machine. It is checked against the projected cost of the request, so the request that would cross the cap is the one that gets blocked. Three sources, first one wins:

1# 1. Set it on an existing run
2curl -s localhost:4100/v1/runs/nightly-20260905-a1b2c3 \
3 -H 'content-type: application/json' -d '{"cap_usd": 12}'
4
5# 2. Set it when the run opens, from the wrapper or the header
6relayplane run --label nightly-backfill --cap 12 -- ./orchestrate.sh
7# X-RelayPlane-Run-Cap-Usd: 12
8
9# 3. Fall back to the config default for every run
10# "attribution": { "defaultRunCapUsd": 12 }

Caps are in notional list price USD. Past the cap the proxy answers 429 with x-relayplane-run-cap-exceeded: true and this body:

1{
2 "error": "run_budget_exceeded",
3 "type": "run_budget_exceeded",
4 "run_id": "nightly-20260905-a1b2c3",
5 "spent": 11.97,
6 "cap": 12,
7 "hint": "Raise with POST /v1/runs/<id> {cap_usd} or relayplane run --cap"
8}

Every block is recorded in the kill history, so it shows up in relayplane kills alongside the global kill switch and the daily cap. Set "attribution": { "runCapAction": "warn" } to observe first: the request is served and tagged with x-relayplane-run-cap-warning instead of being blocked. The default cap is null, so nothing changes until you opt in.

429 waves and model drift

A rate-limit wave is the early signal that a run is about to take much longer and cost more than usual. When a run collects five 429 or 529 responses inside 60 seconds, the proxy fires run.rate_limit_wave once, then at most once every five minutes for that run. Thresholds live in attribution.rateLimitWave.

Model drift is tracked two ways, both exact, no heuristics. Within a run, any request where the served model differs from the requested model counts toward drift_count, which is how you notice the proxy itself downgraded a step. Across runs of the same label, a change in an agent's dominant model between windows fires run.model_drift. That is the "a step quietly moved models" alarm.

1relayplane runs alerts --since 24h
2curl -s 'localhost:4100/v1/runs/alerts?since=24h'

Alerts also POST once to attribution.alerts.webhookUrl when you set one (JSON, 5 second timeout, no retry), and are forwarded into the proxy's existing alert manager so anything already wired there sees them too. Alerts never block a request. Only the cap does.

What inference cannot see

Inferred runs are a good default, not a substitute for tagging. The honest limits:

  • Threads split on compaction. A thread is identified by the system prompt plus the first user message. When a client compacts a conversation, the first user message changes and the same logical conversation appears as a new thread.
  • Gap runs are a time window, not a job. With no run header and no Claude Code session id, the proxy groups by a client fingerprint and an idle gap, producing gap-<client>-<timestamp>. Two unrelated jobs from the same client inside the idle window land in one run. Tag them and this goes away.
  • Traffic that never reaches the proxy is invisible. Codex and anything talking straight to a provider API are not attributed, because attribution is a property of the request path. This is a real hole in any "total spend" number, and it is why the numbers we publish about our own pipeline say which parts are excluded.

Export

Everything is local, in SQLite, and yours. Export one run or a window of runs as CSV, JSON or JSONL, one row per request:

1relayplane runs export --days 7 --format csv --out runs.csv
2relayplane runs export nightly-20260905-a1b2c3 --format json

The CSV columns, in order:

1run_id, run_label, parent_run_id, run_source, agent_label, thread_id, trace_id, ts,
2model, requested_model, provider, tokens_in, tokens_out, cache_read, cache_creation,
3cost_usd, latency_ms, success, status_code, complexity, task_type, tags, attempt,
4is_retry, retry_reason, cache_state

cost_usd is notional list price. Prompt and response text are never included unless you ask for them explicitly with include_content on the API, which appends system_prompt, user_message and response_preview.

Configuration

The full attribution block in ~/.relayplane/config.json, with shipped defaults. It is hot-reloaded, so you do not need to restart the proxy after editing it.

1{
2 "attribution": {
3 "enabled": true,
4 "inferRuns": true,
5 "inferThreads": true,
6 "idleCloseSeconds": 600,
7 "retentionDays": 30,
8 "rollupRetentionDays": 365,
9 "defaultRunCapUsd": null,
10 "runCapAction": "block",
11 "rateLimitWave": { "enabled": true, "count": 5, "windowSeconds": 60 },
12 "bands": {},
13 "alerts": { "webhookUrl": null, "overBand": true, "runCostUsd": null, "modelDrift": true }
14 }
15}
KeyWhat it does
enabledMaster switch. Off means no run ledger is written at all.
inferRunsOpen inferred runs for untagged traffic. Off means only tagged traffic is grouped.
inferThreadsCompute the in-memory thread id from the prompt hashes.
idleCloseSecondsHow long a run may sit silent before it is closed as stale. Swept every 60 seconds.
retentionDaysHow long per-request rows are kept.
rollupRetentionDaysHow long the run and agent rollups outlive the request rows.
defaultRunCapUsdCap applied to runs that ask for none. null means no cap.
runCapActionblock returns 429 at the cap; warn serves and flags it.
rateLimitWaveHow many 429 or 529 responses in how many seconds count as a wave.
bandsExpected-cost ranges per label, keyed by cache state.
alertsOptional webhook, plus switches for band, run-cost and model-drift alerts.

API reference

Every run endpoint is localhost-only. A request from any other address gets 403, no exceptions and no config key to loosen it.

MethodPathReturns
GET/v1/runsPaged run list. Query: days, limit, status, label, tag, source, cursor.
POST/v1/runsOpens a run. Body: run_id, label, parent_run_id, tags, cap_usd. Mints an id when none is given.
GET/v1/runs/activeRunning runs, each with burn per minute, projected cost at idle close and a 429-wave flag.
GET/v1/runs/bandsConfigured and suggested bands for ?label=, plus the sample count per cache state.
GET/v1/runs/alertsRun alert feed. Query: since (epoch, ISO or 24h), run_id, limit.
POST/v1/runs/exportRendered export. Body: format, run_ids or days, include_content.
GET/v1/runs/:idFull detail: run, agents, model mix, child runs, retries, band, drift and alerts.
POST/v1/runs/:idUpdates one run. Body: cap_usd (or null to clear) and label.
POST/v1/runs/:id/endCloses the run and returns the same detail payload. Body: optional exit_code.
GET/v1/runs/:id/requestsPaged request rows for one run. Prompt text only when dashboard.showRequestContent is explicitly true.

Privacy

The run ledger lives in ~/.relayplane/runs.db on your machine. Run ids, labels, agent names, tags and costs are never uploaded. The only thing attribution adds to the anonymous lifecycle pings is three counters, run.first_attributed, run.milestone_10 and run.milestone_100, which carry no ids and no labels. Turn them off entirely with relayplane lifecycle off.