Skip to main content
Laravel, thinking fast.
Chapter 9 · Tool Results Are Not Answers

One Card That Updates, Not Five That Stack

Julian Beaujardin

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:

  1. Extract links first, replacing each with a placeholder that cannot occur in real text.
  2. Escape the remainder — everything the model wrote is now inert.
  3. Apply the small set of formatting you allow — here, bold.
  4. Substitute the links back, already built and individually escaped.

The bug that forced this: escaping first meant a URL containing &amp; became &amp;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.