# Step classes

Three words that say what kind of model call this is. What each means, and what the proxy fills in when you leave it out.

Source: https://tokentriage.com/docs/build/observability-step-classes

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

An agent loop and a chat turn look identical over HTTP. Both are a POST with messages in it.
They behave nothing alike: a chat turn is one call with a fresh prompt, and a tool loop is six
calls that share almost all of their input and reuse the provider's prompt cache across the
whole run.

If your cost report cannot tell them apart, the most expensive shape in your traffic is
invisible. `x-tt-step-class` is the header that tells them apart, and this page is about what
the three values mean and what happens when you leave it out.

## The three values

The vocabulary is closed. A value outside it is ignored rather than stored.

| Value | The call is | Typical shape |
|---|---|---|
| `user_turn` | a person or a caller asking something new | one call, fresh prompt, no tool traffic |
| `tool_loop` | one iteration of an agent working through tools | many calls, near-identical prefix, tool results in the body |
| `provider_state` | a call that maintains provider-side state rather than answering | no user-visible output |

`provider_state` is the one people ask about. It covers calls whose purpose is upkeep rather than
an answer, and it exists so that housekeeping traffic can be separated from work when you split
spend by class.

## What happens when you leave it out

The class is always present on the row. When the header is absent the proxy guesses one from
the shape of the request body, and records that it guessed.

The guess is a single rule: a request that carries a `tools` array or any tool-result content is
classed `tool_loop`, and anything else is classed `user_turn`. `provider_state` cannot be seen in
the body and is never guessed.

The row keeps the two facts apart:

| Field | Values | Means |
|---|---|---|
| `step_class` | one of the three | the class in force for this row |
| `step_class_source` | `header` or `derived` | whether you said so or the proxy guessed |

<Callout type="warning">
A rollup by step class mixes asserted and derived classes without saying so. In a run where 61
requests carried no header, `top --by step-class` filed all 61 under `user_turn`, and the table
gives no hint that the classification was a guess. Filter or group on `step_class_source` before
you draw a conclusion from a step-class split, or instrument the header and re-measure.
</Callout>

Here is that mix, counted over one lab run against a mock upstream:

```text
  61  step_class='user_turn'   source='derived'  trace.source='none'
   3  step_class='tool_loop'   source='header'   trace.source='x-tt'
   1  step_class='user_turn'   source='header'   trace.source='x-tt'
   1  step_class='user_turn'   source='derived'  trace.source='x-tt'
```

The last line is the interesting one: that request carried a trace id, a step id and an agent
name, and no step class. Identity came from the headers and the class came from the guess, which
is a perfectly normal row and one you would misread if you assumed the two travel together.

## The guess informs routing, and never identity

The derived class is a real input. Routing conditions can read `trace.step_class`, so a rule that
sends tool loops to a cheaper tier fires on derived classes too.

What the guess cannot do is change who a row belongs to. Trace, step, parent and agent come from
their own headers, and no amount of structural inference moves a row into a different trace. The
split matters because the two carry different risk. A wrong routing input costs you one
suboptimal decision that the ledger records and you can audit later. A wrong identity silently
corrupts every total you group by it.

## What `tool_loop` changes at request time

Marking a call `tool_loop` has one behavioural consequence beyond reporting, and it is worth
knowing before you set it.

When `routing.cache_affinity` is enabled, a `tool_loop` step is pinned to its served tier from
the first turn, rather than waiting for an observed cache read to trigger a pin. The reason is
economic: a tool loop reuses a large cached prefix across its iterations, and letting per-step
routing move the loop between upstreams forfeits the provider's prompt-cache discount on every
move. Traffic of any other class pins reactively, once a cache read is actually seen.

That is the only place the class changes what happens to the request. Everywhere else it is a
label. [Routing limits](/docs/build/routing-limits) covers affinity in full.

<Details summary="Choosing between the header and the guess">

Set the header when you know. It costs one line and it turns a guess into a statement.

Rely on the guess when the caller is out of your control. It is right about the common case, and
`step_class_source` keeps it honest for anyone reading the numbers later.

Do not set the header to a value you are unsure of in order to avoid a `derived` marker. A
mislabelled `tool_loop` pins affinity and skews the class split, and both are harder to notice
than a `derived` marker you can filter on.

</Details>

<NextSteps items={[
  {title: "How steps become a tree", href: "/docs/build/observability-trace-trees", description: "What the parent edge builds, and how a class split reads next to an agent split."},
  {title: "Put identity on the wire", href: "/docs/build/observability-headers", description: "Where to set the class alongside the trace, step and agent headers."},
  {title: "Routing inputs", href: "/docs/build/routing-inputs#trace--the-agent-and-the-step", description: "The trace fields a routing condition can read, including the step class, and when each is absent."},
  {title: "Routing limits", href: "/docs/build/routing-limits", description: "Cache affinity in full: what a pin does, how long it holds, and when it is dropped."},
]} />
