Docs / Every scenario, end to end
Every scenario, end to end
An agent doesn't make one model call — it makes hundreds, and the safe reflex of sending every one to the strongest model is why an afternoon of agent work bills like a research run. Firstpass runs the cheapest model first, proves its output against a real check, and escalates only when the proof fails. You pay for strength exactly when strength is needed — and every decision leaves a receipt that says why.
Why routing, and why proof#
You've seen the idea: run the cheapest rung first, prove the actual output against a gate, escalate one rung only when the proof fails — cheap-and-proven for the easy majority, strong exactly when strength is earned. The reason that's worth a whole page of scenarios is that agent traffic is messy. Point Claude Code, the Agent SDK, or anything speaking the Anthropic or OpenAI wire at Firstpass, and it emits completion requests by the hundred — every reasoning step, every tool-result read, every subagent its own POST, the whole growing transcript riding along on each. A clean idea is easy to state on one request; the test is whether it stays clean when the shapes get real. It does — and the rest of this page is that idea holding its shape, in one picture first:
Because the decision is proof and not a prompt-time guess, most of the cheap rung's answers turn out good enough to serve as-is — and the ones that don't escalate. That isn't a hope: on a live run of 974 real coding tasks, each scored by its real unit tests, the cheap rung's pass rate is measured:
What a request looks like#
A request is a standard provider call. Firstpass accepts two wire dialects and matches whichever the client speaks:
| Field | What it is |
|---|---|
POST /v1/messages | Anthropic dialect — what Claude Code and the Agent SDK send. |
POST /v1/chat/completions | OpenAI dialect — for OpenAI-shaped clients. |
"model" in the body | A real model, or the sentinel "auto". On the enforce path this is overwritten by the rung the router picks — see the lifecycle. |
messages / tools | The full prompt and the entire conversation history so far. The client sends it every turn — the model is stateless, so history rides in the body. |
Five optional headers are the only channel through which the client tells Firstpass anything beyond the raw call. They are how routing gets its signal — and how one deployment routes different traffic to different ladders:
| Header | Purpose |
|---|---|
x-firstpass-agent | Which agent this call belongs to. Becomes a routing feature. |
x-firstpass-subagent | Which subagent (e.g. architect, test-runner). Becomes a routing feature. |
x-firstpass-mode | Per-request override of the route's cost/quality preset (cost, quality, …). Highest-precedence preset signal. |
x-firstpass-session | Groups requests into one session — for the audit trail and the per-session budget cap. The only conversation-aware knob. |
x-firstpass-key | Tenant key, when multi-tenant auth is on. |
match = {}). Headers don't enable routing; they let you split traffic across ladders. No tags means one ladder for everything, which is a fine place to start.Here is a real one, exactly as Claude Code puts it on the wire when you ask it to fix a bug. Nothing about it is Firstpass-specific — it is a stock Anthropic call, plus two optional headers:
POST /v1/messages
x-firstpass-agent: claude-code # optional — becomes a routing feature
x-firstpass-session: agent-run-4417 # optional — budget + audit grouping
{
"model": "auto", // sentinel — the route's ladder overwrites it
"max_tokens": 4096,
"stream": true,
"system": "You are Claude Code, …", // the agent's system prompt
"tools": [ // the agent's toolbelt — 3 tools this call
{ "name": "Read", "input_schema": { … } },
{ "name": "Edit", "input_schema": { … } },
{ "name": "Bash", "input_schema": { … } }
],
"messages": [
{ "role": "user", "content": "find the bug in handler.rs and fix it" }
]
}The router splits this one request into two things it treats very differently. From the headers and a few structural signals — the tool count (3), whether any message carries an image (no), a coarse bucket of the prompt size — it builds the routing features. It never reads system or the message text to decide; the routing signal is deliberately content-blind. Then, once a route is matched, the entire body — system, messages, tools, unchanged — is forwarded to whichever rung answers. Features decide where the request goes; the body is what the model actually reads.
The life of one request#
POST — one-shot question, tool-loop step, subagent call, turn 17 of a conversation — travels the same four steps: extract features → match route → enforce or observe → serve and record. No fast path, no special cases. Uniformity is what makes per-request proof safe to run on every call.This is the concrete pipeline behind the four moves — the same route → prove → escalate → serve you met in How it works, drawn as the exact steps a request takes on the wire. Every scenario below is this one spine with different inputs: no fast path for "simple" prompts, no special case for subagents. The uniformity is the point — it's what lets one engine stay correct across every case that follows.
- Extract features. From the headers (
agent,subagent) plus cheap body-derived signals — tool count, whether images are present, a coarse prompt-size bucket, an hour bucket. Firstpass never reads your prompt text to route; it buckets by token count, which keeps the routing signal privacy-preserving. - Match a route.
route_for(features)walks your[[route]]blocks top-to-bottom and returns the first whosematchpredicate holds. Every field you set inmatchis an AND-constraint; every field you omit is a wildcard;match = {}catches everything. - Enforce or observe. The matched route's
modedecides. Enforce runs the cascade: open on the cheapest rung, run the gate, serve if it passes, else climb one rung — overwriting the request'smodelwith the rung's model as it goes. Observe forwards the original request byte-identically (also the safe fallback when a request can't be routed faithfully). - Serve and record. The winning output is returned in the caller's own dialect. The full decision — every rung tried, every gate verdict, the cost — is written to a hash-chained receipt.
The scenarios, one by one#
Those four steps produce exactly five outcomes — and every request Firstpass serves is one of them. Scan the map, then follow any row to its real request, response, and receipt. The served_from field on the receipt is how your pipeline tells the outcomes apart after the fact.
| Scenario | What the router does | The receipt says |
|---|---|---|
| Cheap model is right | Rung 0 answers, the gate passes, it's served. No escalation, no waste. | served_from: "attempt" · rung 0 |
| Cheap model falls short | Rung 0's output fails the gate; climb one rung; rung 1 passes and is served. | served_from: "attempt" · rung 1 |
| Nothing clears the gate | Ladder or budget runs out with no pass; the best attempt is served, flagged unproven. | served_from: "best_attempt" |
| A provider is down | A rung 5xxs → it abstains → fail over to the next rung, even on another provider. | an abstain, then pass on a new provider |
| Observe mode | Forwarded byte-for-byte to the model you asked for; the cascade runs only in the shadow. | mode: "observe" |
served_from on the receipt is the branch point for downstream code.A simple question#
served_from: "attempt", served_rung: 0. This is the common case — 82% of outputs on a live coding run clear the gate at rung 0.Start with the easy case. You ask a one-shot question that needs no tools. The agent sends one request; Firstpass opens it on rung 0 (the cheapest model), the gate passes, and that output is served. One request, one rung, done. Here is the actual call:
# "model":"auto" hands the choice to the matched route's ladder
curl localhost:8080/v1/messages \
-H 'content-type: application/json' \
-d '{
"model": "auto",
"max_tokens": 256,
"messages": [{"role": "user", "content": "Capital of France?"}]
}'The response comes back as an ordinary Anthropic completion — the same shape the provider would have returned, because from the client's side nothing about the wire contract changed:
{
"id": "msg_01F3k…",
"type": "message",
"role": "assistant",
"model": "claude-haiku-4-5", // the rung that actually answered
"content": [{ "type": "text", "text": "Paris." }],
"stop_reason": "end_turn",
"usage": { "input_tokens": 14, "output_tokens": 4 }
}The receipt for that request has a single attempt on rung 0 with served_from: "attempt" and served_rung: 0. No escalation, no waste. The whole cascade for an easy request is the cheapest model — the gate just confirmed it was good enough to ship. The interesting case is when it isn't.
served_from: "attempt" · served_rung: 0 — the cascade ran, the gate confirmed, the cheapest answer shipped.A complex question#
Now a real task: "find the bug in this handler and fix it." The agent can't answer in one shot — it reads files, reasons, edits, re-reads, and concludes. That is a loop, and every iteration is its own request:
- Request 1 — reason about the task, decide to read
handler.rs. - Request 2 — with the file contents now in the messages, reason about the bug.
- Request 3 — propose an edit; a
cargo-testgate runs against the result. - Request 4 — with the test outcome in the messages, confirm the fix.
Zoom in: request 3, where escalation happens
Firstpass routes each one independently and proves each one independently. Zoom into request 3, where the edit is gated by the project's own test suite. The cheap rung takes the first pass, its patch fails cargo test, and the cascade climbs one rung — which passes. This is the whole product in one request:
That request writes one receipt. It is a JSON object whose field names are the audit contract — every rung tried, every gate verdict, the cost, and the previous record's hash. This is the real serialized shape, straight from the trace record:
{
"trace_id": "b7e1c2a0-…-4417",
"prev_hash": "sha256:a91f7c…",
"tenant_id": "acme",
"session_id": "agent-run-4417",
"ts": "2026-07-20T18:42:07Z",
"mode": "enforce",
"policy": { "id": "static@v0", "explore": false },
"request": {
"api": "anthropic.messages",
"prompt_hash": "sha256:3d9b…", // salted hash — never the prompt text
"features": {
"version": 1, "task_kind": "code_edit", "language": "rust",
"agent": "claude-code", "prompt_token_bucket": 11,
"tool_count": 4, "has_images": false, "hour_bucket": 18
}
},
"attempts": [
{ "rung": 0, "model": "anthropic/claude-haiku-4-5", "provider": "anthropic",
"in_tokens": 2000, "out_tokens": 700, "cost_usd": 0.0007, "latency_ms": 900,
"gates": [{ "gate_id": "cargo-test", "verdict": "fail",
"cost_usd": 0.0, "ms": 3100, "reason": "2 tests failed" }],
"verdict": "fail" },
{ "rung": 1, "model": "anthropic/claude-sonnet-5", "provider": "anthropic",
"in_tokens": 2000, "out_tokens": 800, "cost_usd": 0.0121, "latency_ms": 1200,
"gates": [{ "gate_id": "cargo-test", "verdict": "pass", "cost_usd": 0.0, "ms": 2950 }],
"verdict": "pass" }
],
"final": {
"served_rung": 1,
"served_from": "attempt",
"total_cost_usd": 0.0128,
"gate_cost_usd": 0.0,
"total_latency_ms": 2100,
"escalations": 1,
"counterfactual_baseline_usd": 0.0630,
"savings_usd": 0.0502
},
"hash": "sha256:5c02d9…" // SHA-256 of this record — the next record's prev_hash
}Read that receipt and the routing is fully explained: the cheap rung was tried ($0.0007), its output failed a real test (not a guess — cargo-test ran and returned fail), the cascade escalated exactly once, and the served answer cost $0.0128 against a $0.0630 always-strong baseline. Request 4 in the same loop might pass on rung 0, because "did the edit apply cleanly" is easy. The router never assumes that because turn 2 was hard, turn 4 will be — it re-earns the answer every time. No routing decision is ever carried forward, because none is kept.
served_from: "attempt" · served_rung: 1 — a real gate ran, one escalation bought correctness; the receipt counts every dollar.Nothing clears the gate#
served_from: "best_attempt" so downstream code can detect and branch on it. Prefer a hard error? Set on_exhausted = "error".Sometimes the task is hard enough that no rung passes the gate — or a per-request budget cap stops the climb first. Firstpass never returns nothing, and never pretends. It serves the strongest attempt it saw and stamps the receipt served_from: "best_attempt" — a flag your pipeline can branch on. Here a strict json-valid gate rejects every rung, and a $0.02 per-request cap halts the climb before the top model is ever tried:
served_from: "best_attempt" is the machine-readable signal that the answer shipped unproven; the receipt still counts every dollar spent.# a genuinely hard ask behind a strict json-valid gate
curl localhost:8080/v1/messages \
-H 'content-type: application/json' \
-d '{
"model": "auto",
"max_tokens": 512,
"messages": [{"role": "user",
"content": "Return ONLY valid JSON: every US state with its capital and 2020 population."}]
}'Both rungs run, both fail the gate, the cap stops the climb before opus, and rung 1's output — the best of a bad set — is served. The receipt tells the whole story, and the honesty is in one field:
{
"trace_id": "c4a2e1f0-…-9f31",
"mode": "enforce",
"request": { "api": "anthropic.messages", "prompt_hash": "sha256:7b1e…" },
"attempts": [
{ "rung": 0, "model": "anthropic/claude-haiku-4-5", "provider": "anthropic",
"in_tokens": 60, "out_tokens": 900, "cost_usd": 0.0012, "latency_ms": 1400,
"gates": [{ "gate_id": "json-valid", "verdict": "fail", "reason": "prose before JSON" }],
"verdict": "fail" },
{ "rung": 1, "model": "anthropic/claude-sonnet-5", "provider": "anthropic",
"in_tokens": 60, "out_tokens": 1100, "cost_usd": 0.0185, "latency_ms": 2100,
"gates": [{ "gate_id": "json-valid", "verdict": "fail", "reason": "trailing comma, line 148" }],
"verdict": "fail" }
],
"final": {
"served_rung": 1,
"served_from": "best_attempt", // ← no gate passed; best effort shipped, flagged
"total_cost_usd": 0.0197,
"total_latency_ms": 3500,
"escalations": 1,
"counterfactual_baseline_usd": 0.0630
}
}on_exhausted = "error" and an exhausted ladder returns a structured error with served_from: "error" instead of a body. Either way the outcome is explicit — see the guarantee.served_from: "best_attempt" — unproven, flagged, and still real bytes. Never a silent bad answer, never a router error in place of a completion.A provider is down#
5xx is an abstain, not a gate failure. The cascade tries the next rung — which can live on a completely different provider. The client sees one ordinary response; only the receipt reveals the failover. A hard 4xx stops immediately — the request itself was wrong, and another provider can't fix that.Providers have bad minutes. When a rung returns a 5xx or drops the connection, Firstpass treats it as an abstain on that rung — not a gate failure, not a served answer — and moves to the next rung, which can live on a different provider entirely. The client sees one ordinary response; only the receipt reveals the failover. The ladder here crosses providers on purpose:
The response comes back in your dialect as always — the only tell is the model field, which names the rung that actually ran:
{
"id": "msg_01Rb7…",
"type": "message",
"role": "assistant",
"model": "openai/gpt-4o-mini", // answered by the failover rung — in Anthropic shape
"content": [{ "type": "text", "text": "Paris." }],
"stop_reason": "end_turn",
"usage": { "input_tokens": 14, "output_tokens": 4 }
}The receipt records the abstain and the cross-provider serve — the failover is auditable after the fact:
{
"trace_id": "e91c3d55-…-2a70",
"mode": "enforce",
"attempts": [
{ "rung": 0, "model": "anthropic/claude-haiku-4-5", "provider": "anthropic",
"in_tokens": 0, "out_tokens": 0, "cost_usd": 0.0, "latency_ms": 240,
"gates": [], // provider errored before any output could be gated
"verdict": "abstain" },
{ "rung": 1, "model": "openai/gpt-4o-mini", "provider": "openai",
"in_tokens": 14, "out_tokens": 4, "cost_usd": 0.0001, "latency_ms": 380,
"gates": [{ "gate_id": "non-empty", "verdict": "pass" }],
"verdict": "pass" }
],
"final": {
"served_rung": 1,
"served_from": "attempt",
"total_cost_usd": 0.0001,
"total_latency_ms": 620,
"escalations": 1
}
}4xx means the request itself was wrong — malformed body, bad auth — and a stronger model on another provider can't fix that. So the ladder stops immediately with served_from: "error" and a structured error to the caller. Failover is only for provider-side faults (5xx, transport), where the same request may well succeed elsewhere.abstain = try the next rung. Cross-provider failover is automatic when your ladder spans providers; the client never sees the switch.Observe mode: shadow, zero change#
Before Firstpass changes a single response, run it in observe — the default on a fresh install. Every request is forwarded byte-for-byte to the exact model you asked for, and the response you get is precisely what the provider returns. Firstpass still scores the cascade in the shadow and writes a receipt, so you collect the savings estimate and the audit trail with zero behavior change. Set it per route, or per request with a header:
# x-firstpass-mode: observe forwards byte-for-byte and only shadows the cascade
curl localhost:8080/v1/messages \
-H 'content-type: application/json' \
-H 'x-firstpass-mode: observe' \
-d '{
"model": "claude-sonnet-5",
"max_tokens": 256,
"messages": [{"role": "user", "content": "Summarize this PR in one line."}]
}'The response is the upstream model's, untouched — model is the one you requested, not a rung. The receipt records the mode and the shadow verdict that feeds the savings estimate over time:
{
"trace_id": "7f0a19c2-…-1c88",
"mode": "observe", // nothing was changed — this receipt is measurement only
"request": { "api": "anthropic.messages", "prompt_hash": "sha256:11ac…" },
"attempts": [
{ "rung": 0, "model": "anthropic/claude-sonnet-5", "provider": "anthropic",
"in_tokens": 900, "out_tokens": 40, "cost_usd": 0.0126, "latency_ms": 1100,
"gates": [{ "gate_id": "non-empty", "verdict": "pass" }], // gated in the shadow, for learning
"verdict": "pass" }
],
"final": {
"served_rung": 0,
"served_from": "attempt",
"total_cost_usd": 0.0126,
"escalations": 0
}
}Subagents#
POST — structurally identical to any other request. Set x-firstpass-subagent on the call, write a [[route]] block that matches it, and the subagent gets its own ladder with its own gate. A `match` value can be a list, so many mechanical subagents share one route. Context never leaks between agents.When the main agent spawns a subagent, the subagent runs its own loop — and every call in that loop is, again, an independent POST to Firstpass. Nothing about a subagent call is structurally different from a main-loop call. So how does Firstpass route a planner subagent to a strong model and a mechanical one to a cheap model? You tag the request. The orchestrator sets x-firstpass-subagent on the subagent's calls, and a route matches on it:
# the planner: enforce a tall ladder with a quality bias
[[route]]
match = { subagent = "architect" }
mode = "enforce"
routing_mode = "quality"
ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5", "anthropic/claude-opus-4-8"]
# mechanical subagents: one cheap rung is plenty — and match takes a list
[[route]]
match = { subagent = ["test-runner", "explore"] }
mode = "enforce"
routing_mode = "cost"
ladder = ["anthropic/claude-haiku-4-5"]
# everything else — the wildcard catch-all, always last
[[route]]
match = {}
mode = "observe"Two schema details do most of the work here. mode is enforce or observe — whether the route acts or just shadows. routing_mode is the cost/quality preset on top of that. And a match value can be a single string or a list (any-of), so a batch of mechanical subagents shares one route.
Agent teams & dynamic workflows#
POSTs. There is no team object and no coordinator inside the router — Firstpass matches each request to a route by its own features and routes it independently. The one team-level concept is the shared session budget: give every worker the same x-firstpass-session and they draw from one pool.A team of agents — or a dynamic workflow that fans out dozens of workers in parallel — looks, from Firstpass's seat, like nothing new: more concurrent requests. Ten agents running at once is ten streams of independent POSTs. Each is matched on its own features (agent, subagent, plus task_kind and language if your client sets them) and routed to the ladder you configured for that kind of worker. There is no "team" object and no coordinator inside the router; concurrency is just volume.
What ties a team together is not routing — it's budget. Give every request in a workflow the same x-firstpass-session, and the per-session cap (per_session_usd) governs the whole team's spend as one pool. That is the one place a multi-agent workload is treated as a unit, and it is a spend guardrail, not a routing input.
POST.x-firstpass-session ties the team's spend to one budget cap.Multi-turn & "previous context"#
This is the question that trips people up, so here is the blunt version: the router does not remember your conversation, and that is the feature. Statelessness is what makes per-request proof safe to run on every call — a router that pinned "this conversation needs Opus" would be trading a correctness guarantee for a cost guess.
Where does the context go, then? Into the request body. An LLM is stateless — every turn, the client (Claude Code, the SDK) resends the entire message history so the model can "remember." That history is part of the POST Firstpass forwards. So the context absolutely reaches the model — it just rides in the prompt, not in any router-side memory. You can watch it happen: turn 2's body is turn 1's body with the last exchange appended verbatim.
// TURN 1 — the POST body carries a single message
"messages": [
{ "role": "user", "content": "find the bug in handler.rs and fix it" }
]
// TURN 2 — same conversation. Turn 1 is kept verbatim; the model's
// reply and the tool's output are appended. Nothing is dropped.
"messages": [
{ "role": "user", "content": "find the bug in handler.rs and fix it" },
{ "role": "assistant", "content": [
{ "type": "text", "text": "Reading the handler." },
{ "type": "tool_use", "id": "toolu_01A", "name": "Read",
"input": { "path": "handler.rs" } }
] },
{ "role": "user", "content": [
{ "type": "tool_result", "tool_use_id": "toolu_01A",
"content": "pub fn handle(req: Req) -> Resp { /* … */ }" }
] }
]What actually carries across turns
The body grew from one message to three, and it only ever grows — every future turn stacks on top of this one. Here is the payoff that surprises people: because the whole thread rides in the body, the rung that answers turn 12 can be a different model than answered turn 1 and lose nothing. Each call reads the transcript cold, so continuity lives in the bytes, not in any one model's memory. Turn 12 re-enters the cascade at the same start rung as turn 1 and is proven from scratch. Two things, and only two, thread across the calls of one conversation:
| Thread | What it carries across turns |
|---|---|
x-firstpass-session → budget | A spend cap and an audit grouping. The only conversation-aware knob. It never changes which model is picked — only whether the session has money left. |
| Learned start-rung (optional) | An off-by-default bandit (ADR 0007) can learn a smarter entry rung for a class of traffic — keyed by request features, not by conversation. It moves where the climb begins; the gate still decides what ships, so it can never change correctness. |
Claude Code vs the Agent SDK#
Both are just HTTP clients that speak the Anthropic dialect, so both wire up the same way: point the base URL at Firstpass. Everything on this page applies identically to either — the differences are only in how you set the base URL and headers.
# Claude Code — send every call through Firstpass instead of the API
export ANTHROPIC_BASE_URL=http://localhost:8080
# now `claude` runs its whole loop — reasoning, tools, subagents — through the router// Claude Agent SDK — same idea, set the baseURL on the client
const client = new Anthropic({ baseURL: "http://localhost:8080" });
// optional: tag calls so subagent-aware routes can fire
// headers: { "x-firstpass-subagent": "architect" }ANTHROPIC_BASE_URL. Agent SDK: baseURL on the client constructor.What you configure, and what's automatic#
Everything above is capability out of one seat. Three facts about that seat are worth stating plainly — not as caveats, but as the dividing line between what Firstpass does for free and what you switch on:
| Automatic — zero config | You switch on — by tagging or setting a mode |
|---|---|
| Every call routes. With no headers, all traffic matches the wildcard route and runs one ladder — correct and safe, just undifferentiated. | Differentiation. A subagent call and a main-loop call are byte-identical POSTs; Firstpass tells them apart only when the client sets x-firstpass-agent/-subagent. Tagging is a wrapper or SDK request-option away. |
| Observe mode forwards byte-identically and still writes receipts, so you get the savings estimate with zero behavior change. | Enforce. Start in observe, read the shadow receipts, and flip to an enforce mode when the data convinces you. The switch is per-route. |
| The cascade proves and serves each request; the receipt counts its cost against an always-strong baseline. | Long-context spend. History rides in every request, so a long session inflates every rung's input tokens. The cascade doesn't summarize history — that's the client's job — so name it, measure it on the receipts, and cap it with per_session_usd. |
per_session_usd caps total spend.How Firstpass tackles all of it#
Every scenario on this page reduces to the same handful of moves. There is not much to it:
| You have… | Firstpass sees… | You steer it with… |
|---|---|---|
| A simple question | One request → rung 0 + gate | Nothing — the default just works |
| A complex task | Many independent requests, each re-proven | The ladder + gate per route |
| Subagents | Tagged requests matched to their own route | x-firstpass-subagent + [[route]] |
| Agent teams / workflows | More concurrent requests, matched individually | Per-worker routes + shared session budget |
| Multi-turn conversation | Independent turns; history rides in the body | x-firstpass-session → per_session_usd |
The design holds because it is not clever about state. It does not predict what your conversation will need. It proves each request against a gate and serves the cheapest output that clears it. Statelessness is what makes that safe to run on every call. Differentiation comes from the tags you send. The one thing carried across a conversation — the budget — is a guardrail, not a guess. That is the whole model: proof over prediction, one request at a time.