Skip to main content
Laravel, thinking fast.

Chapter 9

Tool Results Are Not Answers

Julian Beaujardin

A tool returns JSON. The model reads that JSON and writes a sentence about it.

If you let the sentence be your interface, you have built a chat that describes a product instead of a product. The model will dutifully write "I found four colour palettes: Ocean uses #1B4965 and #62B6CB, Sunset uses…" — and you have asked a human being to read hex codes in a chat bubble and type one back. That is not a choice. It is a quiz.

The structured result is the interesting part. This chapter is about rendering it.

Branch on Keys, Not on Tool Names

Each tool result carries a payload, and the handler decides what to render by looking at what is in it:

$resultText = $result['content'][0]['text'] ?? '';
$data = json_decode($resultText, true);

if (! $data) {
    return $state;
}

if (isset($data['palettes']) && is_array($data['palettes'])) { /**/ }
if (isset($data['options_prompt']) && is_array($data['options_prompt'])) { /**/ }
if (isset($data['available'])) { /**/ }
if (isset($data['site_id'])) { /**/ }

Keying on payload shape rather than tool name means a renaming upstream does not break the interface, and two tools that return the same kind of thing share one renderer for free. It is also why Chapter 7 insisted on naming result keys after their tool — options_prompt, not options. This dispatch is exactly what a generic key would have broken.

Note the early return. A result that will not decode is skipped in silence, not thrown. The model has still produced text for this turn; a payload you cannot parse should cost the user a card, not the whole reply.

The branches are independent ifs rather than elseif, because one result may legitimately carry several renderable things at once.

Status Messages Are Not Chat Messages

Rendered results go into the same transcript as the conversation, but they are not speech:

$state['messages'][] = [
    'role' => 'status',
    'type' => 'palette',
    'card' => 'pal-'.substr(md5((string) json_encode($data['palettes'])), 0, 8),
    'palettes' => $data['palettes'],
    'prompt' => $data['prompt'] ?? '',
];

A third role beside user and bot, carrying a type the front end switches on. Everything the card needs is on the message; the view does no lookups and no derivation.

This keeps the transcript a single ordered list — which is what makes it trivially replayable, because a card and the sentence before it stay in the order they happened.

Give Every Card an Identity

That card key is a content hash, and it exists because of a real bug:

// Identified like a choice card so dismissing one palette does not also
// dismiss the next one.
'card' => 'pal-'.substr(md5((string) json_encode($data['palettes'])), 0, 8),

A conversation can produce two choice cards. Without an identity, the front end tracks "the card" as one thing — so answering the second marks the first as answered too, and re-rendering resets both.

Hashing the content rather than using a counter means the id is stable across re-renders. The transcript is republished after every iteration and the whole list is re-read; an index-based id would shift as messages are inserted, and the UI would lose track of which card the user had already dealt with.