One Card That Updates, Not Five That Stack
Progress is not a series of events. It is one thing changing:
private function updateOrAddProvisioningCard(int $progress, array $state): array
{
foreach (array_reverse(array_keys($state['messages'])) as $index) {
if (($state['messages'][$index]['role'] ?? '') === 'status'
&& ($state['messages'][$index]['type'] ?? '') === 'provisioning') {
$state['messages'][$index]['progress'] = $progress;
return $state;
}
}
$state['messages'][] = ['role' => 'status', 'type' => 'provisioning', 'progress' => 0 + $progress];
return $state;
}
Find the existing card and mutate it; only append if there isn't one. Searching in reverse because the relevant card is the most recent.
Append-only would produce five progress cards at 30%, 55%, 80%, and a reader scrolling back through a stack of stale progress bars. Some results are events and some are state. Events append; state updates in place.
Rendering Model Text Without an Injection
The model's prose is untrusted input. It contains user-supplied fragments, it may contain markdown, and it goes into HTML.
The obvious implementation — escape everything, then convert markdown — has a bug that took a while to find:
private function parseMessageMarkdown(string $content): string
{
// Links come out of the raw text first. Escaping the whole string up front
// double-encodes a URL that already contains an entity — an `&` in the
// source became `&` and the href resolved wrong.
$links = [];
$content = preg_replace_callback(
'/\[([^\]]+)\]\((https?:\/\/[^\s\)]+)\)/',
function (array $m) use (&$links): string {
$token = "\x00LINK".count($links)."\x00";
$label = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', e($m[1])) ?? e($m[1]);
$links[$token] = '<a href="'.e($m[2], false).'" target="_blank" rel="noopener noreferrer">'.$label.'</a>';
return $token;
},
$content
) ?? $content;
$safe = e($content);
$safe = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $safe) ?? $safe;
return strtr($safe, $links);
}
The order is the whole design:
- Extract links first, replacing each with a placeholder that cannot occur in real text.
- Escape the remainder — everything the model wrote is now inert.
- Apply the small set of formatting you allow — here, bold.
- Substitute the links back, already built and individually escaped.
The bug that forced this: escaping first meant a URL containing & became &amp;, and the link resolved to the wrong address. The e($m[2], false) on the href is the second half — false disables double-encoding for a value that may already contain entities.
Two rules worth generalising.
Allow-list your formatting. Bold and links, not a full markdown parser. Every construct you support is surface area, and a chat bubble needs almost none of it.
Escape once, deliberately, at a point you chose. Most double-encoding bugs are two well-meaning escapes at different layers.
Let the Result Drive the Application, Not Just the View
Some payloads change more than the transcript:
if (isset($data['site_id'])) {
$state['siteId'] = $data['site_id'];
$state['siteStatus'] = 'provisioning';
$state['progressPercent'] = 30;
$state = $this->updateOrAddProvisioningCard(30, $state);
}
if (isset($data['status'])) {
if ($data['status'] === 'provisioning' && ! isset($data['site_id'])) {
$state['progressPercent'] = min($state['progressPercent'] + 25, 85);
$state = $this->updateOrAddProvisioningCard($state['progressPercent'], $state);
}
if ($data['status'] === 'completed') {
$state['siteStatus'] = 'completed';
$state['progressPercent'] = 100;
$state['siteUrl'] = SiteUrl::normalise($data['display_url'] ?? $data['url'] ?? '');
$state['messages'][] = ['role' => 'status', 'type' => 'completed', 'url' => $state['siteUrl']];
}
}
Note min($state['progressPercent'] + 25, 85). Progress here is not measured, it is inferred — each status check nudges the bar and it is clamped below 100 so it can never claim completion before completion arrives. Honest imprecision: the bar moves, and only the real signal finishes it.
Note also $data['display_url'] ?? $data['url'] ?? '', then normalised. A payload assembled several services away will change shape eventually; a chain of fallbacks and one normaliser costs a line and survives that.
And the whole handler is wrapped:
catch (\Exception $e) {
report($e);
}
A malformed payload from one tool must not take down a turn that has already produced good work. Report it, keep the state you have.
What This Chapter Argued
- Do not let the model's prose be your interface. Render the structured result; the sentence is commentary.
- Branch on payload keys, not tool names, so renames don't break rendering and similar results share a renderer.
- Skip an undecodable payload quietly. It should cost a card, not the reply.
- Give status messages their own role and a
type, carrying everything the view needs. - Identify every card by a content hash, so two cards in one transcript keep their own state across re-renders.
- Distinguish events from state. Events append; state updates the existing card in place.
- Extract links, then escape, then format, then reinsert. Escaping first double-encodes URLs that already contain entities.
- Allow-list formatting rather than running a full markdown parser on untrusted text.
- Clamp inferred progress below completion, and only let the real signal finish it.
- Wrap result handling so one malformed payload cannot discard a turn's good work.