What identity is on the wire
Attribution comes from headers your code sets. What each identity header means, and what happens to it before the request is forwarded.
On this page
An agent run is a dozen model calls that belong together. Your gateway sees a dozen unrelated HTTP requests. Something has to say which ones are the same run, which step spawned which, and which sub-agent made each call, and that something is a set of request headers your application sets.
This page is about what those headers mean, why none of them is required, and what happens to them between your client and your provider. If you are here to write the code, put identity on the wire is the how-to.
The six headers
Two of them build the tree, two of them label it, and two change how the request is handled.
| Header | What it says | Shape |
|---|---|---|
x-tt-trace-id |
which run this call belongs to | opaque, capped at 128 bytes |
x-tt-step-id |
which step within the run | opaque, capped at 128 bytes |
x-tt-parent-id |
which step spawned this one | the parent’s x-tt-step-id |
x-tt-agent |
which logical agent made the call | free text, capped at 128 bytes |
x-tt-step-class |
what kind of step this is | user_turn, tool_loop or provider_state |
x-tt-tier |
serve this from a named tier | a tier name from your config |
The values are opaque. TokenTriage does not parse a trace id, require a format, or check that a parent exists; it records what you sent and groups on equality. A value longer than 128 bytes is truncated at the byte boundary rather than being rejected, so an over-long id costs you a collision risk, not a failed request.
Two of the six behave differently from the rest.
x-tt-step-class is filled in with a guess when you
omit it, and the guess is marked as one. x-tt-tier is an enforcement input rather than an
identity field: it forces a tier in route mode, it is recorded in every mode, and its header
name is configurable through routing.overrides.tier_header, which defaults to x-tt-tier.
Every one of them is optional
A request with no identity headers at all is priced, logged, counted in the metrics, and
exported. It lands as a standalone row with an empty trace block, and it is aggregable by
model, provider, tier and credential like any other. What it cannot do is join a tree.
The row says which case it was, in trace.source:
trace.source |
Means |
|---|---|
x-tt |
at least one of trace-id, step-id, parent-id or agent arrived |
traceparent |
none of those arrived, but a W3C traceparent did |
none |
the request carried no trace identity at all |
That list is worth reading closely, because x-tt-step-class and x-tt-tier are not in it. A
request carrying only a step class records trace.source: none: the class was read and stored,
and it did not give the request an identity.
Here is the trace block of a row from a request that sent no headers, taken from a local run
against a mock upstream. It is still costed; it has nothing to join to.
"trace": {
"trace_id": "", "step_id": "", "parent_id": "", "agent": "",
"step_class": "user_turn", "step_class_source": "derived",
"w3c": "", "source": "none"
}
Nothing is inferred
TokenTriage builds a trace from explicit identity and from nothing else. It does not stitch
calls together by arrival time, by a shared prompt prefix, by source IP, or by conversation
heuristics. It does not adopt an inbound traceparent as the trace id.
The alternative is tempting and it is what makes this worth a paragraph. A gateway that guessed would show a fuller tree on a client that sets no headers at all, which is most clients on day one. It would also be wrong in ways nobody can see. A trace that silently merged two users’ sessions, or split one agent run into six, produces a per-step cost table that looks right and settles an argument in the wrong direction.
So the gap stays a gap. Requests with no identity aggregate under an unnamed bucket you can count, and the fix is to set the headers rather than to trust a heuristic.
What happens to the headers before the request leaves
Every x-tt-* name is removed from the request before it is forwarded upstream. The provider
receives your body and your headers minus TokenTriage’s control namespace.
Read it left to right. The values are read once, early, and copied to the ledger row; the names
are then removed; what continues to the provider carries none of them. traceparent takes the
lower path, because it is not in the x-tt-* namespace and is not TokenTriage’s to remove: it is
copied to the row and also forwarded unchanged.
Three details follow from that, and each has bitten somebody.
Removal is unconditional. The dimension headers are removed whether or not
ledger.dimensions.enabled is set. Recording is a setting; not leaking a customer identifier to
a model provider is not.
The removal list is a list of names, not a prefix match. It covers the identity family, the dimension family, the cache-control and cache-disclosure headers, and the response-only headers, so a client that echoes one of ours back inbound cannot leak it upstream either.
Some x-tt-* headers are yours to send and some are not. x-tt-request-id, x-tt-team and
x-tt-key are stripped inbound and never read as assertions. TokenTriage mints the request id
itself and stamps team and key from the credential that authenticated, so a client cannot bill
its usage to another team by setting a header.
What can go wrong
A proxy in front of TokenTriage drops unknown headers. Service meshes and API gateways
frequently allowlist request headers. If trace.source reads none on every row and your
client is definitely setting the headers, that hop is the first place to look.
The trace id is per-process rather than per-run. A client that mints one id at start-up and reuses it puts a week of traffic in one trace. Mint the id where the run begins.
Two runs share an id. Nothing checks for collisions, so a short or non-random id can merge unrelated runs. A random suffix is enough; the value is opaque and never parsed.
The exact byte caps, and what truncation does
Identity values are capped at 128 bytes each. Truncation happens at a byte boundary, so a multi-byte character can be split, which is acceptable because the values are treated as opaque identifiers rather than as text.
The practical consequence is collision, not corruption. Two ids that differ only past byte 128 become the same id, and their rows merge into one trace. Keep the varying part of an id near the front.
The step-class vocabulary is closed and is checked: a value outside the three known classes is ignored rather than stored, and the class falls back to the derived guess.