Skip to main content
Laravel, thinking fast.

Validated is not the same as ready. The downstream API rejects a payload carrying both shapes at once, so the tool strips the ones that do not apply:

private function shapeCategoryPayload(array $validated): array
{
    $category = $validated['category'] ?? 'business';

    if ($category !== 'event') {
        foreach (['event_start_at', 'event_end_at', 'event_timezone', /**/] as $key) {
            unset($validated[$key]);
        }

        return $validated;
    }

    $event = [
        'start_at' => $validated['event_start_at'],
        'end_at' => $validated['event_end_at'],
        'timezone' => $validated['event_timezone'],
    ];

    // Business fields are meaningless on an event site and the event
    // template has no partials for them.
    foreach (['services', 'address', 'phone', 'hours'] as $key) {
        unset($validated[$key]);
    }

    $validated['event'] = $event;

    return $validated;
}

A model that has been told about both categories will occasionally send a field from the wrong one — not often, but on a long tail of conversations. Stripping at the boundary means that never becomes a downstream 422 that the model then has to interpret and recover from.

Absorb the model's untidiness at your edge. It is cheap here and expensive three services away.

Normalise the Output, Name the Keys Defensively

What a tool returns is consumed twice: by the model, and by whatever renders it. Both benefit from a shape that never varies.

// Normalised here rather than in the client so every consumer of this tool
// gets the same shape, and so a missing description collapses to an empty
// string instead of an undefined key in a Blade template.
$normalised = array_values(array_map(fn (array $option): array => [
    'label' => trim((string) $option['label']),
    'description' => trim((string) ($option['description'] ?? '')),
], $options));

An optional field is optional to the model and mandatory to your template. Fill it here, once, rather than defending against its absence in every consumer.

The result key is chosen with the same defensiveness:

// The key the client keys off. Named for what it is rather than reusing
// 'options', which is common enough in tool payloads that a future tool
// could collide with it by accident.
return Response::json([
    'options_prompt' => [ /**/ ],
    'message' => 'Options shown to the user as tappable cards. Wait for their reply — do not repeat the options as text.',
]);

Because the front end branches on which keys are present, a generic key like options is a collision waiting for the second tool that has options. Name result keys after the tool that produced them.

The Result Is Also a Prompt

Look at that message field again. It is not for the user, and it is not data. It is an instruction, delivered to the model in the middle of a turn, at the moment it is most relevant.

The bigger version appears on the tool that creates something:

'next_action' => 'IMMEDIATELY present the provisioning details to the user:
share everything in site_details, the expected_url, and the account_url.
Tell them provisioning is underway and typically takes less than 60 seconds.
Then ask if they would like you to check the status. Only call
GetSiteStatusTool when the user asks.',

That exists because of a real behaviour: after starting a long job, the model would poll the status tool in a loop and narrate every check — still cooking, checking again — which was noise, and which contradicted the progress card on screen whenever the two disagreed.

The fix was not in the system prompt. It was here, in the result, where the model reads it at the exact moment it is deciding what to do next. Instructions land better close to the decision they govern.