Skip to content
Chapter 3 of 8 · Streaming tool callsRendering blocks in event order

15 min read

Rendering blocks in event order

On this page

A renderer for a non-streaming, non-tool assistant has one rule: append every piece of text to the current bubble. That rule is correct for as long as text is the only thing on the wire, and it survives the addition of streaming unchanged, which is why it is usually still in place when tool calling arrives. It stops being correct the moment a turn contains a tool call, because the events that mark where one model turn ends and the next begins are exactly the events it discards.

What the reader loses is not formatting. It is the fact that a tool ran, which tool it was, and what it returned — the difference between a reply that cites a lookup and a reply that asserts a payment reference out of nowhere.

Two requests, one bubble

Here is what a text accumulator produces on a turn where the model checks an invoice before answering:

text
Let me check the payment status on that invoice for you.INV-2291 was
paid on 14 February 2026 by bank transfer, reference BT-88214.

The two halves of that paragraph were never part of the same model output. Topic 5.8 established the loop: the model emits a turn containing a call, your code executes it, and you send the whole conversation back with the result attached, at which point the model produces a new turn. The preamble and the payment reference came from two separate inference calls, several hundred milliseconds apart, with a database query between them.

Sequence: Model → App (text "Let me check the payment status...); Model → App (tool_use get_invoice {"id":"INV-2291"}); Model → App (stop_reason tool_use); App → UI (render call block (preparing)); App → App (execute get_invoice → 812ms); App → UI (render call block (ran, 812ms)); App → Model (conversation + call + result); Model → App (text "INV-2291 was paid on 14 February...); App → UI (append second text block).

A renderer whose only rule is "append text deltas to the current bubble" has no concept of a boundary between those two turns, because the only event that marks the boundary is the one it discards. The stop_reason: "tool_use" on the first turn, the call block itself, and the 812 milliseconds spent in the ledger query all pass through a branch that does nothing. What reaches the screen is the concatenation of every text block the loop produced, which reads as one utterance because that is literally how it was assembled.

Inserting a paragraph break between turns improves the typography and fixes none of the problem. The missing information is that a tool ran, which tool it was, and what it returned; a blank line asserts none of that. A model that says "let me check" and then answers two paragraphs later still shows no evidence of checking.

It is worth being precise about why the reviewer's reading is the correct one and not an overreaction. From inside the rendered bubble, an assistant that looked the invoice up and an assistant that produced a plausible reference from nothing emit exactly the same characters. There is no feature of the text that separates them, because the only thing that separated them — the call, its arguments, its result and the time it took — was never given a place on the screen. So a reader has no evidence to weigh, and in the absence of evidence the prudent conclusion about a system known to invent things is that it invented this. The interface is not failing to look trustworthy. It is failing to carry the information that trust would be based on.

Three designs get proposed whenever this comes up, and they differ by more than effort:

Text accumulatorText plus a call summary at the endOrdered block list
Preamble visible while it streamsyesyesyes
Call visible at allnoyesyes
Call shown where it happened—no, always lastyes
Two calls distinguishable—only if the summary lists bothyes
Correct when a call precedes any text—noyes
Reproducible after a page reloadas one paragraphneeds a second storefrom the same data
What it costs to buildnothinga component and a lista component, a list, and coordinates on every event

The middle column is the usual intermediate step, a "used tools: get_invoice" footer under the reply, and it is honest that a tool ran while being wrong about when. That distinction costs nothing in a one-call turn and stops working the moment a turn contains a lookup, a sentence about the lookup, and a second lookup that depends on the first, at which point the footer lists two tools and the reader has no way to attach either one to the sentence it explains.

Blocks, in the order they happened

The structure that solves this is the one the provider is already sending. A turn is an ordered sequence of content blocks, each with an index, and each block is of a kind: text, a tool call, and on some providers a thinking block. The renderer's state is a list of blocks across the whole exchange, and each block routes to the component that knows how to draw that kind.

ts
type Block =
  | { kind: "text"; turn: number; index: number; text: string }
  | { kind: "call"; turn: number; index: number; id: string;
      name: string; status: "preparing" | "ran" | "failed";
      args?: unknown; result?: unknown; ms?: number };

const blocks: Block[] = [];

function onEvent(turn: number, e: StreamEvent) {
  switch (e.type) {
    case "content_block_start":
      blocks.push(
        e.block.type === "text"
          ? { kind: "text", turn, index: e.index, text: "" }
          : { kind: "call", turn, index: e.index, id: e.block.id,
              name: e.block.name, status: "preparing" },
      );
      return;
    case "text_delta":
      find(turn, e.index, "text").text += e.text;
      return;
    case "input_json_delta":
      accumulator.onArgumentDelta(e.index, e.partial_json); // chapter 2
      return;
  }
}

Two things about that switch are load-bearing. Text deltas mutate a text block and argument deltas go to the accumulator, and neither path can reach the other's state — the routing decision is made once, on the event type, at the top. And blocks are appended in arrival order and carry their own (turn, index) coordinates, so the display order is a property of the data rather than of when a component happened to re-render. Ordering by arrival alone is enough for providers that emit blocks sequentially; carrying the coordinates costs nothing and means a stream that announces two blocks before finishing either one still renders in the order the model produced them, rather than in the order the last fragment happened to land.

The distinction hiding in that last clause is between two orderings that are usually identical and occasionally are not. A block has an order in which it was announced and an order in which it was completed, and a renderer has to pick one of them to display by. Announcement order is the model's own sequence: it started the sentence, then it started the call. Completion order is whatever the network and the model's writing speed produced, and a long sentence announced first can finish after a short call announced second. Renderers that keep a list and append on completion are choosing the second ordering without noticing they have chosen anything, which is why they look correct until a provider begins interleaving. Storing the announcement coordinate on the block makes the choice explicit and makes it the right one.

The call block starts life in a preparing state, moves to ran when the dispatcher returns, and carries the elapsed milliseconds. Chapter 5 deals with what preparing looks like on screen and what it must not claim; the point here is that the state exists at all, because it is the thing a reader looking for evidence of a lookup cannot otherwise find.

Two blocks open at once

There is a version of that switch which routes on "the block currently being written" rather than on coordinates, and it appears whenever a renderer keeps a current pointer instead of a list. It survives Anthropic's Messages API, where content blocks open and close strictly in sequence, and it breaks on OpenAI's Responses API, where a message item can still be streaming when a function-call item is added:

text
response.output_item.added              output_index=0  message
response.output_text.delta              output_index=0  "Let me check the payment"
response.output_item.added              output_index=1  function_call  call_31Ab
response.function_call_arguments.delta  output_index=1  "{\"id\": \"INV-"
response.output_text.delta              output_index=0  " status on that invoice."
response.function_call_arguments.delta  output_index=1  "2291\"}"
response.output_item.done               output_index=1
response.output_item.done               output_index=0

A current pointer moves to the function call on the second output_item.added, and the text delta that follows either lands nowhere or, in the version where the pointer is just an index into a list of strings, lands in the argument buffer and corrupts it. With coordinates the sequence is unremarkable: both items exist in the list from the moment they are announced, the two deltas after the announcement update different entries, and the sentence renders whole and above the call even though its second half arrived after the call had started. Interleaved thinking — the mode in which the model keeps reasoning in visible thinking blocks between and around its tool calls instead of doing all of it before the first one — produces the same shape on Anthropic, with a thinking block and a tool-use block both live, which is the case worth having a fixture for since it is the one a feature flag can turn on without touching your code.

What the user sees when the order is kept

The same transcript, rendered from blocks instead of from a text accumulator, becomes three visually distinct things: a sentence, a call, and an answer.

text
Let me check the payment status on that invoice for you.

  ▸ get_invoice · id: INV-2291 · 812ms

INV-2291 was paid on 14 February 2026 by bank transfer,
reference BT-88214.

The middle line is the whole fix. It is collapsed by default, expands to the arguments and the result, and it converts the reply from an assertion into a citation. Users who would never think about tool calls read it as "it looked something up", which is exactly the right amount of understanding, and support engineers read it as the trace that tells them whether a wrong answer came from a wrong lookup or a wrong summary of a right lookup.

Streaming the preamble is worth keeping for its own sake. On the invoice assistant the first text delta lands around 310ms after the request starts, while the full first turn — preamble plus a 40-token argument object — completes at about 1.4 seconds, and the answer built on the tool result does not begin until roughly 2.6 seconds. Discarding the preamble to avoid the collision would trade a real 2.3-second improvement in perceived latency for a formatting convenience, which is why the answer is to render both things separately rather than to render one of them.

Those four figures describe one timeline, and laying them end to end is worth doing because it shows where each of them comes from. The request goes out at zero. The first text delta lands at 310ms, which is the model's time to its first token and the earliest anything can appear on screen. The model keeps writing — the rest of the sentence, then the announcement, then the 40-token argument object — and finishes the turn at about 1.4 seconds. The dispatcher then runs get_invoice, which takes 812ms, putting the second request on the wire at a little over two point two seconds. That request has its own time to first token, comparable to the first one's, which is what lands the answer's opening words at roughly 2.6 seconds.

The 2.3 seconds is the subtraction across that timeline: the answer's first words at 2.6 seconds, less the preamble's first words at 310ms. That is the quantity a user experiences, and it is worth being clear that it is not a saving in total time. The turn still finishes when it finishes; nothing about streaming the preamble makes the ledger query faster. What changes is when the screen stops being empty, and an empty screen is the only part of the wait a person counts.

The same order after a reload

The block list is also the shape worth storing. A user who reloads the conversation gets the transcript back from your database, and if what you stored was the concatenated text of each turn then the original bug has been reintroduced at rest: the reply renders as one paragraph again, the call is gone, and this time it is the version that survives restarts. Storing blocks with their (turn, index) coordinates, the tool name, the arguments as they were finally parsed and the result lets the reloaded view render through the same components as the live view, which also leaves one place to change when the call block's design changes.

Reloads are when the trace gets read. On the invoice assistant 4.1% of rendered call blocks were expanded by a user, and the expansions clustered on turns whose next user message was a correction — people open the trace when the answer looks wrong, which is precisely the moment it needs to still be there. A view that exists only while the stream is open is missing at the only time anyone wants it.

The expansion rate is easy to read the wrong way round. Four expansions in every hundred rendered call blocks sounds like a feature almost nobody uses, and read as an average it is. What the clustering shows is that the expansions are not spread evenly across turns: they concentrate on the turns whose next user message was a correction. So the right denominator is not all turns, it is the turns where something went wrong, and against that denominator the rate is high. A usage percentage measured over every turn answers the question "how often is this looked at", and the question that decides whether to build it is "is it there when it is looked for". Those two questions have the same numerator and different denominators, and only the second one is about the feature.

The persisted block is not the same object as the message you send back to the model on the next turn. That request carries the provider's own tool-use and tool-result shape from topic 5.8, and it carries no display state: no preparing, no elapsed milliseconds, no field labels. Keeping the two representations separate, with the request derived from the stored blocks at send time, is what stops something that exists for the screen from turning into something the model reads as history, which is the failure chapter 8 takes apart.

The reason to derive one from the other rather than to store both is that two stored copies of the same turn drift, and the drift is silent. A field added for the display — a label, a duration, a collapsed flag — is harmless in the view and becomes part of the conversation if the same object is what you send. Going the other way, a call whose arguments were repaired at dispatch time is stored once, and both the screen and the next request see the same repair. One store, two projections, and the projection toward the model deliberately drops everything the model has no use for. Renderers change often and the wire format does not, which is a further reason not to let the wire format be whatever the renderers happened to need last quarter.

What the providers do not agree on

Providers do not agree on what may follow a tool call inside a single turn. On Anthropic's API a turn ends when it emits tool calls, so post-call prose in the same turn only appears with interleaved thinking enabled, and the ordinary shape is text-then-calls-then-stop. OpenAI's Responses API can emit a message item and a function-call item in the same response, and the ordering between them is expressed through output_index rather than through arrival timing. Writing the renderer against block coordinates rather than against an assumed shape is what keeps it correct across both, and across a provider swap that no one warned you about.

Nor is a preamble something you can require. The model may emit a call with no text before it at all, and a renderer that assumes text always comes first will either crash on an empty text block or, more often, display an empty bubble above the call. It may also emit a preamble and then no call, having talked itself out of it, which leaves a sentence saying "let me look that up" as the entire reply — an honest rendering shows exactly that and nothing else, rather than inventing a pending indicator for a call that was never announced.

The rule underneath both of those cases is that the interface renders what was announced and never what it expects. A spinner shown because a preamble usually precedes a call is a claim about the future, made by your component, on no evidence — and if the call never arrives the spinner is a lie that stays on screen until something times out. A pending state is legitimate only once content_block_start has named a call, because at that point the existence of the call is a fact the provider stated. Every state a call block can be in should be traceable to an event that indicated it: announced, arguments complete, dispatched, returned.

The last thing to notice about the preamble is that it makes claims. "Let me check the payment status" is harmless, because checking is what happened. "I'll create that ticket for you now" is a different kind of sentence, because it commits the assistant to a side effect in text that is already on the user's screen and already, once the turn is stored, part of the conversation history. When the call behind that sentence completes, the promise and the record agree.

The rule that comes out of all of this is short. Keep one list, append blocks in the order they were announced, carry their coordinates, and let each kind of block draw itself — text as prose, a call as a call. The evidence the reviewer was looking for is then a property of the data rather than something a component reconstructs, and it survives being written to a database and read back.

For now the exchange renders honestly and in order, which raises the question of when the call block is allowed to leave preparing — because the buffer holding its arguments becomes readable, in the sense of parsing into a plausible object, well before the call reports itself finished.

Streaming tool calls — read the current version at https://daviddominguez.dev/learn/en/streaming-tool-calls/rendering-blocks-in-event-order

Back to top