Chapter 5 of 8 · 15 min read
Displaying a call as it forms
On this page
Between the preamble finishing and the call block appearing complete there is a stretch of a second or more in which the interface has nothing to say. A spinner fills it, and it is the least informative element on the screen at the moment the system is doing the most interesting thing. Users notice: asked what the assistant was doing during those pauses, they say they do not know.
The obvious fix is to show them the call taking shape, and the obvious implementation of that fix — pipe the buffer to the screen — is wrong in three separate ways.
Three ways to fill 1.4 seconds
Piping the accumulator's buffer straight to the screen takes about four lines and produces this at delta 22 of 47:
Preparing create_ticket
{"project": "BILLING", "title": "March invoice run failed", "priorThat is worse than the spinner. It exposes the reader to a data format they did
not ask to see, it is visibly broken in a way that reads as a bug, and it will
occasionally contain a value nobody intended to display — an email address, an
internal account id, the body of a message being drafted on someone's behalf.
Escaped characters make it stranger still: a customer name containing an umlaut
renders as Müller on screen for as long as it takes the parser you are not
running to decode it.
| Spinner | Raw buffer | Stabilised fields | |
|---|---|---|---|
| What the user sees | nothing about the call | JSON, mid-token | field labels and settled values |
| Malformed text on screen | never | routinely | never |
| Sensitive values exposed | no | yes, unfiltered | only fields you chose to show |
| Misreads as an error | no | often | no |
| Implies execution started | ambiguous | ambiguous | no, if labelled |
| Survives a 1.2s gap in deltas | yes | looks frozen mid-word | yes, last state is coherent |
| Work to build | none | trivial | a partial parser and a field map |
The third column is the one to build, and the two rules that make it safe are that nothing reaches the screen until it has stopped changing, and that nothing on screen claims the call is running until the dispatcher has actually run it.
What "stabilised" has to mean
A field is stabilised when the parser can see that its value is terminated, which
for a string means the closing quote has arrived, and for a number means a
delimiter — a comma, a closing brace — has arrived after the digits. The number
case is the one that catches people, because a bare 12 in the buffer is a
perfectly good integer and there is no way to tell it apart from the first two
digits of 1250. Rendering "amount: €12" for 80 milliseconds before it becomes
"amount: €1250" is exactly the kind of flicker that makes a user distrust the
number they eventually read, and on a refund_amount field it is the kind of
flicker that generates a support ticket.
The asymmetry between the two cases is worth stating plainly, because it decides
what your parser has to give you. A JSON string carries its own end marker: the
closing quote is a character that appears in the document, so the moment it
arrives the value is known to be finished and no later byte can extend it. A JSON
number carries no end marker of its own. It ends because something that is not
part of a number turns up next — a comma, a brace, a space — which means a run
of digits is finished only in the light of the character after it, and that
character may not have been sent yet. Every value type in the format falls on
one side of that line. Booleans and null are fixed-length keywords and settle
the instant their last letter lands; strings settle on their quote; numbers,
objects and arrays settle only when the following delimiter or closer appears.
So "has it stopped changing" is not a judgement about whether the value looks complete. It is a question about whether the document has yet contained the character that ends it, which is why a parser that reports its open path is the right tool and a heuristic on the rendered text is not. Any rule of the form this looks like a finished amount is a rule that will be wrong on exactly the values where being wrong is expensive.
A tolerant streaming parser gives you this directly, because reassembling
fragmented arguments into a partially-known object is what those parsers are for;
streamjson is a small reference implementation of the algorithm, and Fahd
Mirza's write-up of a streaming JSON preprocessor covers the same ground with
function calls specifically in mind. What you need from whichever one you use is
not just a best-effort object but a marker of which path is still open, so you
can exclude it:
type PartialParse = { value: Record<string, unknown>; openPath: string[] };
function stabilisedFields(buf: string): Record<string, unknown> {
const { value, openPath } = parsePartial(buf); // tolerant, returns open path
if (openPath.length === 0) return value;
const out = { ...value };
delete out[openPath[0]]; // the key currently being written
return out;
}At delta 22 of the create_ticket capture the buffer holds {"project": "BILLING", "title": "March invoice run failed", "prior, the partial parse yields {project: "BILLING", title: "March invoice run failed"} with an open path of ["prior"], and the display shows two rows. The partial key prior never appears, which also spares you the rendering of a field name that will turn out to be priority
two deltas later.
That
delete holds only for flat argument objects, and it fails in both directions as soon as a schema contains an object or an array. A call carrying {"filter": {"status": "open", "since": "2026-0 has an open path of ["filter", "since"], so deleting filter throws away
status, which had settled and was worth showing, while deleting nothing leaves since
on screen as 2026-0, which is the flicker the rule exists to prevent. Pruning
the exact path serves both cases:
function prune(value: unknown, path: string[]): unknown {
if (path.length === 0) return undefined;
const [head, ...rest] = path;
if (Array.isArray(value)) {
const copy = value.slice();
const i = Number(head);
if (rest.length === 0) copy.splice(i, 1);
else copy[i] = prune(copy[i], rest);
return copy;
}
const copy = { ...(value as Record<string, unknown>) };
if (rest.length === 0) delete copy[head];
else copy[head] = prune(copy[head], rest);
return copy;
}Arrays want the same treatment for a slightly different reason. The last element of a partially written array is the one still being written, so a list that grows an item and then rewrites its final entry in place reads as a glitch even when every intermediate state is technically true. Dropping the open element gives a list that only ever grows, which is what a reader expects from something being filled in.
A stable frame, filled in place
Field values are not the only thing that has to hold still. Rendering keys in
arrival order means every new field pushes the layout, and 47 deltas produce a
block that jitters for a second and a half. Rendering the tool's declared
parameters in schema order, with unfilled ones simply absent, gives a stable
frame that fills in place — the row for title occupies the same position
whether it arrived second or fifth. This also gives you the natural place to
decide what is displayable at all: the field map that turns project into
"Project" is the same map that marks raw_customer_email as never rendered.
The map is small and belongs next to the tool's schema rather than inside the component:
const display: Record<string, FieldDisplay> = {
project: { label: "Project", order: 1 },
title: { label: "Title", order: 2 },
priority: { label: "Priority", order: 3, format: titleCase },
description: { label: "Description", order: 4, mode: "stream" },
raw_customer_email: { hidden: true },
};A field absent from the map defaults to hidden, which is the safe direction to fail in: a tool gains a parameter, nobody updates the display, and the new field does not render rather than appearing on a customer's screen unreviewed. The cost is that a genuinely useful new field stays invisible until somebody adds a label, which is a review task rather than an incident.
Choosing the default this way is the whole of the design, and the reasoning generalises past display. Both defaults are wrong sometimes, so the question is never which one is right but which failure you would rather have. Defaulting to visible fails by showing an unreviewed value to a customer, which is an event you cannot undo and may have to report. Defaulting to hidden fails by omitting something useful from a diagnostic panel, which somebody notices and fixes in an afternoon. The two failures are not comparable in cost, and when they are not, the default belongs on the cheap side even if that side is wrong more often. The same argument decided the field map's location: keeping it beside the schema means a change to the arguments and a change to what is shown appear in one diff, in front of one reviewer.
Some calls have nothing to stabilise for a long time. A send_email whose body
runs 800 characters produces an object where the first field settles in 90
milliseconds and the last does not settle until the call is nearly over — a
median of 3.2 seconds on this assistant, with a p90 of 5.1 — so a strict
stabilised-fields display shows one row and then holds still for longer than the
spinner it replaced. Showing the field's label with an indeterminate marker
beside it, "Body — writing", says a value is coming without showing a value that
will change. Streaming the body text itself is available too, and it is a
decision about that field rather than a general rule: a long free-text field the
user is going to read anyway can stream like prose, since a partly written
sentence is meaningful at every prefix in the sense chapter 1 described, while an
identifier, an amount or an enum is not. The mode entry in the map above is
where that choice is recorded, which keeps it reviewable.
Two calls in one turn each get their own forming block, keyed by the same index the accumulator uses. On a provider whose blocks are sequential the second block sits announced and empty while the first fills, which is accurate and reads as broken to anyone expecting a spinner, so a "queued" label on an announced call with no fragments yet costs one state and removes the question.
The indicator says preparing until it is not
The states a call block passes through are visible in the renderer from chapter 3, and the distinction that matters is between the last two:
◐ Preparing create_ticket
Project BILLING
Title March invoice run failed
◑ Running create_ticket …
▸ create_ticket · 412ms · TICK-9034The first state is driven by argument deltas, the second begins the moment the dispatcher is called, and the third is the result. Nothing about the first state may borrow the vocabulary of the second: no "creating ticket", no progress bar, no optimistic row appended to the ticket list, and no animation that a user would read as work happening on their behalf. The reason is the one chapter 4 established from the other direction — a call that is still being written may never be dispatched at all, because the arguments can fail to parse, fail the schema, or never complete, all of which chapter 6 covers. A user who has watched a "creating ticket" indicator for a second and a half and then sees it vanish has been told something false, and if the turn ends there they will go looking for a ticket that does not exist.
The argument for "Creating ticket" is worth reconstructing before dismissing it, because it is not careless. Its premise is that the call almost always does run, so the label is almost always accurate, and a label that is accurate almost always is better than one that is accurate always and colder. What that premise misses is which turns it is wrong on. The label is wrong exactly on the turns where the arguments failed to arrive, failed to parse or failed the schema — which is to say, on the turns where something has already gone wrong and the user most needs an accurate account of what happened. A statement's value is not its average truth rate. It is whether it holds in the cases that make somebody act on it, and every case where this one is false is a case where somebody goes looking for a ticket.
The general rule this is an instance of: an interface state may report an event that has occurred, and may not report an event that is expected. "Preparing" reports that argument deltas are arriving, which they are. "Creating" reports that a dispatcher has been called, which at that moment it has not. The distinction costs one word and it is the same distinction the previous chapter drew about dispatching, arriving here through the display instead of through the database.
That is also the answer to the natural next request, which is a progress bar. There is no denominator. The stream does not say how many fields the call will have, how long the description will be, or whether the model is about to stop; the schema's field count is an upper bound only if the model fills every optional field, which it usually does not. An indeterminate indicator is honest and a percentage is not.
Work through what such a percentage would be computed from and the objection stops being a matter of taste. A proportion needs a completed quantity divided by a total quantity. The completed part is available — bytes received, or fields settled. The total is not, and cannot be, because it is decided by tokens that have not been produced yet. Nothing in the stream announces a length, nothing announces a field count, and the model itself has no figure to announce until it has finished writing. Substituting the schema's field count for the total gives a bar that stalls at some fraction and then jumps to done on most calls, and on a call with an unusually long description it would have to move backwards, which is the one thing a progress bar must never do.
Arriving unevenly
Deltas do not land at a steady rate. On the same billing assistant the median gap between consecutive argument deltas was 11 milliseconds, the p99 was 84, and about one turn in three hundred contained a single gap over a second — 1.2 seconds in the worst sample, from a provider-side hiccup with no event to explain it. Two things follow. A display that renders per delta is doing up to 90 re-renders a second during the fast stretches, which on a chat surface with a virtualised message list, one that mounts only the rows currently in view, is enough to drop frames on a mid-range phone; coalescing renders on an animation frame, or throttling to around 60 milliseconds, costs nothing perceptible and removes the problem. And because the display can sit unchanged for over a second, whatever is on screen at any moment has to be coherent on its own, which is another reason the open field is excluded rather than shown half-written.
The re-render figure comes straight from the median gap, and the division is worth doing because it is how you decide your own throttle. A delta every 11 milliseconds is a thousand milliseconds divided by eleven, which is a little over ninety deltas in a second, and a render per delta is therefore up to ninety renders in that second. A display refreshes about sixty times a second, so more than half of those renders produce nothing a human could see; they are work done between two frames that are never both drawn. Throttling to around 60 milliseconds puts the update rate below the frame rate on purpose, which costs at most one frame of staleness and removes the surplus entirely.
The p99 and the one-in-three-hundred figures describe the other end of the same distribution and argue for the opposite property. If gaps of 84 milliseconds are ordinary at the tail and a gap of over a second happens on a real fraction of turns, then the display's resting state is not an edge case — it is something a user will sit looking at. A design that is only coherent while it is changing is therefore a design that is incoherent precisely when someone has time to read it.
Screen readers need the same restraint for a different reason. A live region — an
element whose contents a screen reader re-announces whenever they change — that
announces every change will read a growing form aloud in fragments, so the call
block should be aria-live="polite" at most, or better, announce once when the
state changes rather than when the fields change: "preparing create ticket", then
"create ticket completed".
Two adjacent features want the opposite of everything above, and both get asked for. A human approving a tool call has to see the arguments the model actually finished writing, which means the approval prompt appears after the completion event and not during — approving a stabilised subset would approve a request that nobody, including the model, has finished making. And a developer view wants the raw buffer, the delta boundaries, the byte offsets and the escape sequences, because its audience knows what a fragment is. Serving both from one component is how raw JSON ends up in front of a customer; the developer view belongs behind a flag.
What the display gives the user, then, is a call taking shape with nothing on it that will change, holding still when the stream stalls, labelled with a state that is true at the moment it is shown. Every one of those properties is a restriction rather than a feature, which is the usual shape of an interface built over a stream: the work is in deciding what not to show yet.
Each of those restrictions rests on the same event, the call reporting itself finished. That event is doing a lot of work, and it is worth being precise about what it does and does not certify, because a call can report itself finished and still be unusable.
Streaming tool calls — read the current version at https://daviddominguez.dev/learn/en/streaming-tool-calls/displaying-a-call-as-it-forms