Chapter 7 of 7 · 14 min read
Reasoning on human-facing surfaces
On this page
Every system that touches a conversation eventually serialises it for somebody: a support console, an audit record, a browser bubble, an analytics table. The code that does that was usually written when an assistant message was a string, and the natural update — keep the text, drop the rest — throws away the material with the most operational value while keeping the one thing everybody agrees should not be shown to users anyway.
This chapter is about drawing the line in the right place, in both directions. Outbound, that means a serializer that keeps the tool trail and drops the deliberation. Inbound, it means a stream handler that renders one block type and not the other, which is the same decision made at a different layer. And it means declining a proposal that follows every incident in this area: storing the model's reasoning text as an explanation of its behaviour.
Three kinds of block, three sets of rights
A serializer written to keep internal detail out of support tooling usually implements that by keeping the final assistant text and discarding everything else:
def for_support_console(message):
return {
"role": message.role,
"text": "".join(b.text for b in message.content if b.type == "text"),
}The instinct is right and the line is in the wrong place. The deliberation — the raw thinking block, as distinct from the provider's summary of it, which the table below treats separately — is unreviewed generated content and does not belong on any human-facing surface. The visible text is the answer, already the thing the customer saw. And the tool calls with their arguments and results are the record of what the assistant actually did — which order ID it passed, what the service said back — which is the only material that can answer the questions support tooling exists to answer.
The step that goes wrong is a categorisation, and it is easy to miss because the phrase that licenses it — internal detail must not leak into support tooling — does not look like a category. Internal detail was read as anything that is not the answer, and that reading collapses three different kinds of thing into one bucket. Only one of the three is content the model generated with nobody looking at it. The other two are a message a person has already read and a log of requests that left the process, and neither acquires the property that made deliberation dangerous.
The difference between those last two kinds and the deliberation is mechanical rather than editorial. A tool call and its result are records of something that happened outside the model: an identifier the model passed to a service, and a value that service returned. Both can be checked against that service — the identifier can be looked up, the call can be issued again, the returned value can be compared with what the system holds now. Deliberation is generated content, and there is no system outside the model that it corresponds to, so there is nothing to check it against. Support tooling exists to establish what happened, and only one of the two kinds of block is evidence of that in a form a second system can confirm.
That is also why keeping only the final text is not the conservative choice it is taken for. It is conservative about the one artefact already shown to the customer and readable back off their own screen, and destructive about the record of what the assistant did on their behalf — which is the part an investigation runs on.
Recovering that record takes about as many lines as throwing it away:
VISIBLE = {"text"}
AUDIT = {"tool_use", "tool_result", "server_tool_use"}
NEVER = {"thinking", "redacted_thinking", "reasoning"}
def for_support_console(message):
return {
"role": message.role,
"text": "".join(b.text for b in message.content if b.type in VISIBLE),
"actions": [
{"name": b.name, "input": b.input, "id": b.id}
if b.type == "tool_use" else
{"result_for": b.tool_use_id, "content": b.content, "error": b.is_error}
for b in message.content if b.type in AUDIT
],
}
assert not (VISIBLE | AUDIT) & NEVERThe same three-way split applies at every destination, with a different answer in each column:
| Block type | User's chat bubble | Support console | Audit record | Next request to the model |
|---|---|---|---|---|
text | yes | yes | yes | yes |
tool_use (name, input) | usually rendered as an action chip | yes | yes | yes |
tool_result | no | yes | yes | yes |
thinking / redacted_thinking | no | no | no | required inside the task |
| provider-generated summary | product decision | no by default | no | not needed |
The column that surprises people is the last one, because it is the only place where the answer for reasoning is anything other than "no", and it is not a human surface at all — it is chapter 5's obligation to replay the block inside an open tool loop.
Two qualifications keep that table from being read as a licence. Tool results are not automatically safe to store just because they are not reasoning — a call to an internal customer service can return a full profile, and a search tool can return documents the requesting user has no right to see. The trail belongs in the audit record with the same field-level treatment the underlying system already applies, which usually means recording identifiers and status rather than payloads, and reading the payload back from the source system when someone needs it. The rule underneath that is worth stating on its own: a block's storage class follows its content and not its type, so a tool result carries whatever handling its fields already had in the system they came from, and arriving inside a model response neither upgrades nor downgrades that.
The second qualification is that the trail is a record of actions rather than of
reasons: it says the assistant called a service with a particular identifier and
got false, which is a fact, and says nothing about why it chose that
identifier. That is a smaller claim than "we can see why the model answered as it
did", and it is the one that holds up.
The same filter, in the other direction
The inbound half shows up the day someone enables reasoning on a streaming endpoint, as a report that the assistant has started talking to itself — a paragraph of deliberation appearing in the message bubble before the actual answer, sometimes contradicting it, occasionally referring to the customer in terms nobody wanted a customer reading.
The client is rarely doing anything exotic. Written when responses contained one kind of content, it appends every delta it receives, each delta being one fragment of the response as the server emits it:
for await (const event of stream) {
if (event.delta?.text) bubble.append(event.delta.text);
}Streaming reasoning arrives as its own typed block, not as a special case of
text, so the events carry the structure needed to separate them and the client is
ignoring it. A content_block_start event announces the type of the block at a
given index; the deltas that follow reference that index and carry their own
delta type. Keeping a small map from index to type is the whole fix:
const blockTypes = new Map();
for await (const event of stream) {
switch (event.type) {
case "content_block_start":
blockTypes.set(event.index, event.content_block.type);
if (event.content_block.type === "thinking") bubble.showThinkingIndicator();
break;
case "content_block_delta":
if (blockTypes.get(event.index) === "text") {
bubble.append(event.delta.text);
}
break;
case "content_block_stop":
if (blockTypes.get(event.index) === "thinking") bubble.hideThinkingIndicator();
break;
}
}Filtering on delta.type === "text_delta" gets the same result and is fine. What
does not work is any rule that infers the block from the delta's shape, since a
thinking delta and a text delta are both a string under a key. Nothing in the
delta itself says which kind of block it belongs to, because the type is
announced exactly once, in the content_block_start event, and every delta after
it identifies its block only by index. A handler that does not record the type
when the block starts has discarded the only statement of it the stream ever
makes, and anything it decides later is a guess wearing the shape of a check.
That is why the fix is a map from index to type rather than a cleverer condition
inside the delta branch.
Once the filter is in, the indicator matters more than it looks, and the reason is in the ordering. A single stream produces both block types in sequence, so with reasoning enabled the visible answer cannot begin until the deliberation has finished — the first text delta arrives after the last thinking delta rather than alongside it. Filtering thinking out of the bubble therefore does not shorten that wait at all; it converts a bubble filling with the wrong content into a bubble with nothing in it. With reasoning enabled and its display omitted, twenty seconds can pass between the request and the first visible character, and a chat interface with nothing on screen for twenty seconds is read as broken. The fix has two halves for that reason: one that stops rendering the block, and one that renders the fact that a block is being produced. A spinner labelled with something honest, plus the token counter you are already logging, is the difference between a slow answer and a dead one.
The dashboard that would explain everything
The proposal that follows an escalation like that is predictable and comes from a good place: capture the model's reasoning text on every request, store it alongside the conversation, and surface it in an internal quality dashboard so reviewers can see why the model answered as it did. It is offered as pure observability, and it fails on both halves of its own justification.
Internal-only is not a property a dashboard can have. Exposure attaches to a datum rather than to a screen, and the exposure of a datum is the union of every system that can read it and every path out of each of those systems. A claim that something is internal-only is therefore a claim about that whole union, and the union is never what the proposal described: the dashboard is the only member of it anybody had in mind, and the rest were inherited from decisions other teams made years earlier. Written out, the set is always larger than the person proposing it has counted: the warehouse the dashboard queries, the exports reviewers take to spreadsheets, the screenshots that land in ticket threads and incident channels, the third-party BI tool with its own access model, the backups, and the contractors in the review queue.
Reasoning text is unreviewed content generated about a specific customer, so once it is in that set the organisation has taken on a new class of personal data, subject to the same deletion requests, retention limits and breach scope as anything else — and it has done so for a field nobody reviewed before writing it down. The belief that makes this hard to see is that internal tooling is a safe destination, which is true of the tooling and false of the datum: internal systems are where data is copied most freely, precisely because everyone reading them is already trusted.
The second failure is the more interesting one, because it survives even if the storage question is solved perfectly. A reasoning trace is a generated artefact, produced by the same sampling process that produced the answer, and it is not a transcript of the computation that produced the answer. The loop that emits it is the loop that emits everything else: one piece of text at a time, each piece selected from scores over candidate continuations, each conditioned on the prompt and on whatever has already been emitted. Nowhere in that loop is there a step at which the model inspects the computation behind its answer and reports what it finds there. That computation is a pass of arithmetic over weights, and it leaves behind no record the loop could read back and describe.
What the trace contains, then, is text that reads like an explanation, produced because text of that kind is what follows in a deliberation. Its relationship to whatever actually drove the answer is guaranteed by no step of the process, which makes the finding that follows a prediction rather than a curiosity: models can and do write a plausible line of argument that arrives at a conclusion reached on other grounds, and experiments that plant a hint in a prompt find the model using the hint and then explaining its answer without mentioning it. That is what a trace with no inspection step in it is free to do. Said in plain words, a trace is evidence about the model's behaviour and it is not testimony about its reasons.
The claim should not be pushed over in the other direction either. The trace is produced in the same stream that reaches the answer and conditions the text that comes after it, so it is correlated with the answer and often does describe something real about how the response came out. What it cannot be relied on for is the one thing a reviewer wants from it: a factor's absence from the trace is not evidence that it played no part, and its presence is not evidence that it did. A reviewer reading such a trace will diagnose a cause confidently, and the cause may be fiction. Prompt changes will then be made against that fiction, evaluated on a benchmark that moves for unrelated reasons, and kept.
Reaching for the provider's summarised version of the trace feels like the safer option, on the reasoning that a summary is the tidied, reviewed form of a raw artefact. It moves a reviewer one step further from the mechanism rather than one step closer: where the trace has been summarised by the provider, the reviewer is reading a summary of an artefact that was already not the mechanism, produced by a second generation pass with its own freedom to smooth, so the gap between the trace and the cause is still underneath it and now harder to see.
What answers the question the dashboard is actually for is the record from the first half of this chapter, plus the fields chapter 1 put in the log line:
- every tool call with its arguments, its result, and whether it errored
- the documents or rows retrieved, by identifier
- the exact model identifier including snapshot, and the effort or budget setting
- the usage split, the finish reason, and the latency
- the visible answer, and what the user did next
That set explains a refund case completely. It also explains the failures from the middle of this topic — a truncated answer is a finish reason plus a reasoning count, and a degraded agent is a tool-call count — without anyone reading a word of deliberation. If a team still wants deliberation signal for a specific investigation, the defensible version is to fetch the provider's summary at read time for the conversation under review, behind an access control and an explicit purpose, without persisting it, and to treat it as evidence about the model's behaviour rather than as testimony about its reasons.
Enabling reasoning is a four-line configuration change with a fortnight of
consequences, and the consequences are all measurable in advance. Before the
switch, capture the current output-token distribution and answer lengths from a
month of stored responses, so the p99 you will need for sizing exists. Probe the
model's allowance regime with the two mis-sized requests, and write the answer
into the adapter rather than into a wiki page. Enable it on a sample of
production traffic, reconcile the usage payloads with and without, and put the
hidden share and the price per unit of quality gain in front of whoever is paying
for it. Set max_tokens from the measured tail rather than the median, pin the
model to a snapshot rather than an alias, and turn on the two alerts before the
first user request rather than after the first page.
Then read your own code for the four places block types now matter: the tool loop that must append whole content arrays, the middleware that must not rewrite an in-flight task, the serializer that must keep the tool trail and drop the deliberation, and the stream handler that must render one block type and not the other. Those four are the same idea seen from four angles — that a response is a sequence of typed blocks with different obligations, and that code written when it was a string will be wrong about every one of them.
Reasoning-token mechanics — read the current version at https://daviddominguez.dev/learn/en/reasoning-token-mechanics/reasoning-on-human-facing-surfaces