One run, walked end to end
A single tool-calling loop — request, tool_use, tool_result, second request, answer — mapped to the spans it emits, so you can see exactly which fields your component will have and which ones you would be inventing.
Everything so far has been about the shape of the data in general. This lesson is one run, concretely, from the first request to the final answer, with every record it produces named. By the end you should be able to point at any field your component wants to render and say which emitted record it came from — or admit that you were going to invent it.
The run: an agent asked to enrich a property record. It calls a search tool, calls an enrichment tool that fails, retries it, and writes the result. Four tool calls, three model calls, one error. Small enough to hold in your head and large enough to contain every case that breaks a naive renderer.
The loop, at the API level
Start below the framework, because every framework is a wrapper around this and the wrapper is where the disagreements live. Anthropic’s Messages API — Anthropic documenting its own product — structures tool use as ordinary messages rather than as a separate channel. Its own docs make the contrast explicit: “Unlike APIs that separate tool use or use special roles like tool or function, the Claude API integrates tools directly into the user and assistant message structure.”
So one turn of the loop is: the model responds with stop_reason: "tool_use" and one or more tool_use content blocks, and your code sends the results back as tool_result blocks in a message with role user. A tool_use block carries exactly three fields — here in the documentation’s own example, which uses a weather tool rather than the enrichment one above:
{
"type": "tool_use",
"id": "toolu_01A09q90qw90lq917835lq9",
"name": "get_weather",
"input": { "location": "San Francisco, CA", "unit": "celsius" }
}And the result you send back carries the id as tool_use_id, the output as content, and — this is the field your renderer needs most and the one people forget exists — an optional is_error boolean:
{
"type": "tool_result",
"tool_use_id": "toolu_01A09q90qw90lq917835lq9",
"content": "ConnectionError: the weather service API is not available (HTTP 500)",
"is_error": true
}Three structural facts fall out of this that a trace renderer has to respect. First, tool_use_id is the only thing tying a result to its call — not position, not order, and certainly not the tool name, since the same tool can be called twice in one turn. Second, the failure of a tool is not an exception; it is a normal message with a boolean set, which the model then reads and reacts to. A retry is therefore a completely ordinary next turn, indistinguishable at the protocol level from the agent deciding to call the same tool again for its own reasons. Third, the docs impose ordering constraints — results must immediately follow their calls, and tool_result blocks must come first in the content array, with any text after them — which means message order in the transcript is reliable in a way span order is not.
One more thing to know before you render an outcome: tool_use is one of seven documented stop_reason values — alongside end_turn, max_tokens, stop_sequence, pause_turn, refusal and model_context_window_exceeded. A component that treats “the loop ended” as one state has collapsed a run that finished, a run that hit a token ceiling mid-sentence, and a run that refused, into the same green check.
The same loop, as spans
Now the telemetry layer. Vercel’s AI SDK — vendor documenting its own product — publishes what its recommended OpenTelemetry integration emits for exactly this loop, and it is a useful concrete instance because the names follow the OpenTelemetry GenAI conventions rather than the SDK’s own dialect. Three span shapes, with the model id or tool name interpolated into the span name itself:
invoke_agent {modelId} // the run
chat {modelId} // one model call (a "step")
execute_tool {toolName} // one tool execution
chat {modelId} // the next model call
execute_tool {toolName}
...The attributes worth knowing by name, because they are the ones your schema will map from: gen_ai.operation.name (whose value here is one of invoke_agent, chat, execute_tool), gen_ai.provider.name, gen_ai.request.model, gen_ai.agent.name, and on the tool span gen_ai.tool.name, gen_ai.tool.call.id, gen_ai.tool.call.arguments and gen_ai.tool.call.result. Token usage lands as gen_ai.usage.input_tokens and gen_ai.usage.output_tokens.
Notice gen_ai.tool.call.id. That is the same toolu_... identifier from the protocol above, surfaced as a span attribute — which is what lets you correlate the span tree with the message transcript. Those are two different records of the same run, and a component that has both can do things neither alone can: the transcript knows the ordering rules, the spans know the timing.
Where people get burned
The same page documents a second, older integration that emits ai.* attributes instead — span names like ai.generateText, ai.generateText.doGenerate and ai.toolCall, and token counts as ai.usage.promptTokens and ai.usage.completionTokens. Vercel calls it “the legacy format” on its own page and recommends migrating. Both are live in the version the docs describe. If you hard-code either set of names, you have written a component that works with half of one SDK.
Where the two records disagree, and why it matters for cost
The most instructive thing about holding the protocol payload and the span tree side by side is finding the places where one has something the other has thrown away. The clearest example is tokens, and it is not academic — it is the difference between a cost figure that is right and one that is off by several multiples.
The GenAI conventions give you two integers per model call: input and output. Anthropic’s own usage object gives you four, and its prompt-caching documentation spells out both the shape and the trap:
"usage": {
"input_tokens": 2048,
"cache_read_input_tokens": 1800,
"cache_creation_input_tokens": 248,
"output_tokens": 503
}Those four are not four views of one number. The same page states that input_tokens counts only the tokens after the last cache breakpoint, and that the true total is cache_read_input_tokens + cache_creation_input_tokens + input_tokens. They are also priced differently: as of Anthropic’s pricing page fetched 2 September 2026, a cache read costs 0.1× base input, a five-minute cache write 1.25×, and a one-hour cache write 2×. A component that multiplies gen_ai.usage.input_tokens by the base input price on a heavily cached agent run is not slightly wrong; it is reporting a fraction of the input and pricing all of it at the most expensive rate.
A second, sharper version of the same lesson from the same page: it states that Claude 4.7 and later models use a newer tokenizer producing roughly 30% more tokens for the same text. A token count is not a unit that survives comparison across models, which means a chart of tokens per run over a period spanning a model upgrade is a chart of the tokenizer changing.
Check your recall
Answer from memory — no scrolling back.
Hands on
Write the schema, with a provenance tag on every field
Done when: ARTIFACT.md points at a TypeScript type for a run, where every field traces to a named field in the captured payload and carries one of three tags — emitted, derived or asserted — and derived fields state their formula. Still no component.
- Open the two lists from the capture exercise. Everything in list one is a candidate field; nothing outside it is allowed in the type without a tag that says where it came from instead.
- Write the type. Model a run, a step, and a tool call. Resist making it elegant — the point is fidelity to what you actually captured, and an abstraction invented now is an abstraction invented before you have seen a second runtime.
- Tag every field
emitted,derivedorasserted. For derived fields write the formula in a comment: duration isend - start; total input tokens is the sum named above. For asserted fields write where the value comes from and what makes it go stale. - Now handle the retry explicitly. Decide whether your type has a concept of an operation distinct from an attempt. Either answer is defensible; leaving it undecided is what produces the two-identical-calls bug. Write the decision down.
- Add a translation layer as a separate function, not as fields on the type: something that takes a payload in one runtime’s dialect and returns your type. Write it for the runtime you captured, and leave a stub with the field names for one you did not. That stub is what proves the schema is not just a transcription of one vendor.
- Bring the type and the provenance tags into the chat. I will look first at whether anything is tagged
emittedthat isn’t, because that is the mistake that survives all the way into the rendered view.
What this does not cover
The module ends here, and deliberately: there is now a captured payload, a schema, and a provenance tag on every field, and not one pixel has been drawn. Everything that follows is rendering, and it can be rendering rather than guessing because of the work in this module.
What comes next is the altitudes lesson, which takes the schema you just wrote and asks the question this course exists for: which of these facts belongs in a one-line summary, which in a step list, which in the full tree, and which only in the raw payload. Cost gets its own treatment much later, in the per-step cost lesson, where the pricing arithmetic sketched above becomes the actual subject rather than an illustration.
Read this next — primary source
AI SDK Core: TelemetryVercel, AI SDK documentation (version selector reads AI SDK 7.x) — vendor documenting its own product; free
This lesson takes the three span shapes and a handful of attributes. The page itself is a complete emission catalogue — every span the SDK produces, every attribute on each one, for both the current gen_ai.* integration and the legacy ai.* one, side by side on a single page. Reading it in full is the fastest way to build the instinct this module is really after: seeing how much of what you would want to render is present, how much is absent, and how a single vendor can carry two mutually incompatible names for the same integer inside one documented version. Read it with the second list from the capture exercise next to you and mark which of your missing fields this SDK would have given you.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.