Skip to content
Bladewell
Guides

AI agent actions with Approve and Deny in Laravel

Human approval for an AI agent's tools with the Laravel AI SDK: the run pauses, a person approves, changes or denies the action, and the agent carries on.

php artisan bladewell:add chat popover number text-input button toast

Uses Chat, Popover, Number, Text input, Button and Toast. New to Bladewell? Install it first.

An agent that asks before it acts

Give an AI agent tools and it can do things, not only talk: refund an order, cancel a subscription, send an email. The Laravel AI SDK can make it stop first. When the model calls a tool that needs approval, the run pauses and your app gets what the agent wants to do, with the exact arguments. A person approves it, changes it or denies it, and the agent carries on from there, knowing what was decided.

This guide builds that for a support team: an assistant that refunds orders. It records each refund in your database; returning the money through a payment provider is a step you add, shown below. Refunds up to €100 go straight through. Bigger ones appear in the conversation as a card with Approve, Change amount and Deny, built from Bladewell's chat, buttons and popovers.

It was checked against the AI SDK's source (laravel/ai 1.3), and run on Livewire 3 and 4 against a scripted model. That run covered approving, changing an amount, denying with a reason, two actions in one reply, a reload while one waits, a double click, and another person's conversation.

Install

The SDK keeps conversations in two tables of its own, and a paused run lives there until someone decides. Its migrations are published with the rest of its files, not under a tag of their own.

Terminal
composer require laravel/ai
php artisan vendor:publish --provider='Laravel\Ai\AiServiceProvider'
php artisan migrate
php artisan bladewell:add chat popover number text-input button toast

A tool that waits for a person

A tool asks for approval by implementing Approvable and using the SDK's trait. On its own, the trait makes every call wait. needsApproval() decides per call, from what the model asked for: here, only refunds over €100. Its reason is shown on the card.

app/Ai/Tools/RefundOrder.php
namespace App\Ai\Tools;

use App\Models\Order;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\DB;
use Laravel\Ai\Approvals\Approval;
use Laravel\Ai\Concerns\InteractsWithApprovals;
use Laravel\Ai\Contracts\Approvable;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

class RefundOrder implements Approvable, Tool
{
    use InteractsWithApprovals;

    // The person the assistant works for: each refund is recorded in their name.
    public function __construct(private User $user) {}

    public function description(): Stringable|string
    {
        return 'Refund part or all of an order, in euros.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'order_id' => $schema->integer()->description('The order number.')->required(),
            'amount' => $schema->number()->description('How much to refund, in euros.')->required(),
        ];
    }

    // Small refunds go ahead; bigger ones wait for a person. Asked of what the model wants to do, before it's done.
    protected function needsApproval(Request $request): Approval|bool
    {
        return $request['amount'] > 100
            ? Approval::required('Refunds over €100 need a person\'s approval.')
            : false;
    }

    public function handle(Request $request): Stringable|string
    {
        // Checked here, every time: the SDK doesn't check the model's arguments against the schema, and an amount a
        // person changed doesn't go through needsApproval() again. What this returns, the model reads.
        $data = $request->validate([
            'order_id' => ['required', 'integer'],
            // Euros and cents: the refunds column keeps two decimals and would round anything finer.
            'amount' => ['required', 'decimal:0,2', 'min:0.01'],
        ]);

        return DB::transaction(function () use ($data, $request) {
            $order = Order::lockForUpdate()->find($data['order_id']);
            if ($order === null) {
                return "There's no order {$data['order_id']}.";
            }
            // This tool call already refunded: say what it did, before the balance it took counts against it.
            $done = $order->refunds()->firstWhere('tool_call_id', $request->toolCallId());
            if ($done !== null) {
                return 'Refunded €'.number_format((float) $done->amount, 2, '.', '')." on order {$order->id}.";
            }
            // In cents, so float arithmetic can't let a cent too many through.
            $left = (int) round($order->total * 100) - (int) round($order->refunds()->sum('amount') * 100);
            if ((int) round($data['amount'] * 100) > $left) {
                return "Order {$order->id} can be refunded €".number_format($left / 100, 2, '.', '').' at most.';
            }
            // This records the refund; it doesn't return any money. To pay it out, call your payment provider here,
            // with the tool call id as its idempotency key, so a retry can't pay twice.
            $order->refunds()->create([
                'tool_call_id' => $request->toolCallId(),
                'amount' => $data['amount'],
                'refunded_by' => $this->user->id,
            ]);

            return 'Refunded €'.number_format((float) $data['amount'], 2, '.', '')." on order {$order->id}.";
        });
    }
}

Two things the SDK leaves to the tool, so handle() checks them on every run. It doesn't check the model's arguments against the schema. And when a person changes the amount, the new one isn't put through needsApproval() again. Amounts are euros and cents, nothing finer, and the balance is counted in cents. Each refund also records the tool call that made it, in a unique column, so one approval can never refund twice; if the same call runs again, the tool says what it already did rather than checking the balance that refund used up:

database/migrations/…_create_refunds_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('refunds', function (Blueprint $table) {
            $table->id();
            $table->foreignId('order_id')->constrained();
            $table->decimal('amount', 10, 2);
            $table->foreignId('refunded_by')->constrained('users');
            // The AI tool call that made it: unique, so one approval can only ever refund once.
            $table->string('tool_call_id')->unique();
            $table->timestamps();
        });
    }
};

To return the money too, call your payment provider where the comment in handle() says, while the order is still locked. Pass it the tool call id as its idempotency key (Stripe calls it idempotency_key), so a retry can't pay out twice.

The agent

A paused run can only carry on from a stored conversation, so the agent uses RemembersConversations, and the SDK keeps the messages, the pending action and its arguments in its tables. Without a stored conversation, the SDK throws when a tool asks for approval. The tool gets the person the agent works for, so each refund is recorded in their name.

app/Ai/Agents/SupportAgent.php
namespace App\Ai\Agents;

use App\Ai\Tools\RefundOrder;
use App\Models\User;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Attributes\Timeout;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
use Stringable;

// RemembersConversations: the SDK stores the conversation, and with it each action waiting for a decision, so the
// run can carry on once someone decides. Without a stored conversation, an approval can't be resumed.
#[Provider(Lab::Anthropic)]
#[Timeout(120)]
class SupportAgent implements Agent, Conversational, HasTools
{
    use Promptable, RemembersConversations;

    public function __construct(private User $user) {}

    public function instructions(): Stringable|string
    {
        return 'You help the support team with customer orders. To refund one, call RefundOrder with the order number '
            .'and the amount. If a refund is denied, say so and why, and only try again if you are asked to.';
    }

    public function tools(): iterable
    {
        return [new RefundOrder($this->user)];
    }
}

Asking, deciding, carrying on

The page is a Livewire component. send() prompts the agent. If the reply stops at an action, the card shows, and decide() resumes the run with Decisions:

  • Decision::approve() runs the tool as the model asked.
  • Decision::edit([...]) runs it with the person's arguments instead.
  • Decision::reject('…') doesn't run it; the model reads the reason and replies. Always give one: without it, the SDK ends the turn with no reply at all.

When the model asks for more than one action in a reply, each needs an answer before the run can go on, so the component keeps the answers until the last one comes in.

app/Livewire/SupportAssistant.php
namespace App\Livewire;

use App\Ai\Agents\SupportAgent;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use Laravel\Ai\Approvals\Decision;
use Laravel\Ai\Approvals\Decisions;
use Laravel\Ai\Contracts\ConversationStore;
use Laravel\Ai\Contracts\ResolvesPendingApprovals;
use Laravel\Ai\Contracts\VerifiesConversationOwnership;
use Laravel\Ai\Models\ConversationMessage;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Throwable;

class SupportAssistant extends Component
{
    // Set by the server only: the browser can change any other public property.
    #[Locked]
    public ?string $conversationId = null;

    // Answers already given on this turn's actions, while another still waits for one.
    #[Locked]
    public array $answers = [];

    public string $prompt = '';

    // Typed on an action: a different amount, or why it's denied. Validated before use.
    public array $amounts = [];

    public array $reasons = [];

    public function mount(?string $conversation = null): void
    {
        if ($conversation !== null) {
            $this->authorizeConversation($conversation);
            $this->conversationId = $conversation;
        }
    }

    public function send(): void
    {
        $this->validate(['prompt' => ['required', 'string', 'max:2000']]);

        // A new message while an action waits counts as a no: the SDK tells the model it wasn't approved.
        $sent = $this->run(fn (SupportAgent $agent) => $agent->continueOrStart($this->conversationId, as: auth()->user())
            ->prompt($this->prompt));
        // Kept in the box when it wasn't sent, or the reply failed, so it can be sent again.
        if ($sent) {
            $this->reset('prompt');
        }
    }

    public function decide(string $toolCallId, string $answer): void
    {
        if ($this->conversationId === null || ! in_array($answer, ['approve', 'edit', 'deny'], true)) {
            return;
        }
        $given = [$toolCallId => match ($answer) {
            'approve' => ['approve'],
            // Euros and cents: the refunds column keeps two decimals and would round anything finer.
            'edit' => ['edit', (float) $this->validate(["amounts.{$toolCallId}" => ['required', 'decimal:0,2', 'min:0.01']])['amounts'][$toolCallId]],
            'deny' => ['deny', $this->validate(["reasons.{$toolCallId}" => ['nullable', 'string', 'max:500']])['reasons'][$toolCallId] ?? null],
        }];

        $this->run(function (SupportAgent $agent) use ($toolCallId, $given) {
            // What's waiting, read under the lock: another tab may have answered since this page was drawn. An id
            // that isn't waiting would end the turn with nothing done, so it never reaches the SDK.
            $pending = collect($this->store()->pendingApprovalsFor($this->conversationId))->keyBy('id');
            $answers = array_intersect_key([...$this->answers, ...$given], $pending->all());
            // The model asked for more than one: carry on once each has an answer.
            if (! isset($answers[$toolCallId]) || $pending->count() > count($answers)) {
                $this->answers = $answers;

                return null;
            }

            $response = $agent->continue($this->conversationId, as: auth()->user())->prompt(Decisions::from(
                $pending->map(fn ($action, $id) => match ($answers[$id][0]) {
                    'approve' => Decision::approve(),
                    'edit' => Decision::edit([...$action->arguments, 'amount' => $answers[$id][1]]),
                    // Always with a reason: without one, the SDK ends the turn and the model doesn't reply.
                    'deny' => Decision::reject(filled($answers[$id][1]) ? $answers[$id][1] : 'A person on the team denied this.'),
                })->all(),
            ));
            $this->answers = [];

            return $response;
        });
    }

    // One call at a time per person, so a double click or a second tab can't answer the same action twice. Returns
    // false when the call didn't run, so what was typed stays.
    private function run(callable $call): bool
    {
        if ($this->conversationId !== null) {
            $this->authorizeConversation($this->conversationId);
        }
        if (! RateLimiter::attempt('assistant:'.auth()->id(), 20, fn () => true)) {
            $this->dispatch('toast', type: 'error', message: 'Too many messages. Wait a minute, then try again.');

            return false;
        }
        $lock = Cache::lock('assistant:'.auth()->id(), 150);
        if (! $lock->get()) {
            $this->dispatch('toast', type: 'info', message: 'The assistant is still working on your last message.');

            return false;
        }

        try {
            set_time_limit(150);
            $response = $call(new SupportAgent(auth()->user()));
        } catch (Throwable $e) {
            // The model's reply failed, but a tool may have run before it: the SDK keeps what was done, and the
            // conversation shows it.
            report($e);
            $this->dispatch('toast', type: 'error', message: 'The assistant\'s reply didn\'t come through. Check what was done above before asking again.');

            return false;
        } finally {
            $lock->release();
        }

        // A new conversation gets its own address, so a reload or a link comes back to it.
        if ($this->conversationId === null && $response !== null) {
            $this->redirectRoute('assistant', $response->conversationId, navigate: true);
        }

        return true;
    }

    // The SDK continues any conversation by its id, whoever asks: only the person's own may be read or continued.
    private function authorizeConversation(string $id): void
    {
        $user = auth()->user();
        abort_unless($this->store()->conversationBelongsTo($id, $user->getMorphClass(), $user->getKey()), 404);
    }

    // The SDK's database store, which can say whose a conversation is and what in it waits for a decision.
    private function store(): ConversationStore&ResolvesPendingApprovals&VerifiesConversationOwnership
    {
        return app(ConversationStore::class);
    }

    public function render()
    {
        return view('livewire.support-assistant', [
            // The latest 50; their ids are time-ordered.
            'messages' => $this->conversationId === null ? collect() : ConversationMessage::query()
                ->where('conversation_id', $this->conversationId)
                ->orderByDesc('id')->limit(50)->get()->reverse(),
            // The actions still waiting, by tool call id: only these get buttons.
            'pending' => $this->conversationId === null ? [] : collect($this->store()->pendingApprovalsFor($this->conversationId))->pluck('id')->all(),
        ]);
    }
}

Three checks in there matter more than they look:

  • Whose conversation it is. The SDK continues any conversation by its id, whoever asks. Without conversationBelongsTo(), anyone who has the id could approve the refund.
  • That the action is still waiting. Resuming with an id that isn't pending doesn't just fail: the SDK marks the turn failed, and the real action can no longer be approved. So the id is checked against pendingApprovalsFor() first, under the lock: read before it, another tab could answer in between.
  • One call at a time. A lock per person stops a double click or a second tab answering the same action twice, and the rate limit caps paid model calls. When either turns a call away, the message or the buttons stay as they were, to try again.

The arguments themselves never come from the browser: the SDK stored them with the paused run, and only the tool call's id and the person's answer are sent.

The approval card

Each action the assistant took or wants to take shows before its text. Waiting ones get the buttons; finished ones say what happened, or why not. If the model's reply fails, the refund before it may still have been made: the SDK keeps the actions that ran, so the cards show them, and the message says to check them before asking again. "Change amount" and "Deny" open a popover with one field each, so the conversation stays readable. Replies are shown with the chat's markdown, which drops any HTML the model wrote.

resources/views/livewire/support-assistant.blade.php
<div>
    <x-widget.chat label="Support assistant" class="h-[36rem]">
        @foreach ($messages as $message)
            @if ($message->role === 'user')
                <x-widget.chat.message wire:key="message-{{ $message->id }}" mine :time="$message->created_at" :text="$message->content" />
            @else
                {{-- What the assistant did, or wants to do, before what it said. --}}
                @foreach ($message->tool_calls as $call)
                    @php($waiting = in_array($call['id'], $pending, true))
                    <x-widget.chat.message wire:key="call-{{ $call['id'] }}" name="Support assistant" initials="AI">
                        <div role="group" aria-labelledby="call-{{ $call['id'] }}" class="border-line grid gap-3 rounded-xl border p-4">
                            <p id="call-{{ $call['id'] }}" class="font-medium">Refund €{{ $call['arguments']['amount'] }} on order {{ $call['arguments']['order_id'] }}</p>

                            @if ($waiting)
                                <p class="text-muted text-sm">{{ $call['approval_reason'] }}</p>
                                @isset($answers[$call['id']])
                                    <p class="text-sm">Answered. Decide on the other action too, and the assistant carries on.</p>
                                @else
                                    <div class="flex flex-wrap gap-2">
                                        <x-widget.button size="sm" wire:click="decide('{{ $call['id'] }}', 'approve')">Approve</x-widget.button>

                                        <x-widget.popover id="amount-{{ $call['id'] }}" trigger="Change amount" size="sm" variant="secondary" title="Refund a different amount">
                                            <div class="grid gap-3">
                                                <x-widget.number name="amounts[{{ $call['id'] }}]" wire:model="amounts.{{ $call['id'] }}" label="Amount" prefix="€" min="0.01" />
                                                <x-widget.button size="sm" wire:click="decide('{{ $call['id'] }}', 'edit')">Refund this amount</x-widget.button>
                                            </div>
                                        </x-widget.popover>

                                        <x-widget.popover id="deny-{{ $call['id'] }}" trigger="Deny" size="sm" variant="neutral" title="Deny this refund">
                                            <div class="grid gap-3">
                                                <x-widget.text-input name="reasons[{{ $call['id'] }}]" wire:model="reasons.{{ $call['id'] }}" label="Why? The assistant passes it on." maxlength="500" />
                                                <x-widget.button size="sm" variant="danger" wire:click="decide('{{ $call['id'] }}', 'deny')">Deny</x-widget.button>
                                            </div>
                                        </x-widget.popover>
                                    </div>
                                @endisset
                            @elseif (array_key_exists('result', $call))
                                {{-- Denied: the reason given. Otherwise what the tool said it did. --}}
                                <p @class(['text-sm', 'text-error' => $call['denied'] ?? false])>{{ ($call['denied'] ?? false) ? 'Denied: ' : '' }}{{ $call['result'] }}</p>
                            @elseif ($message->status->value === 'failed')
                                <p class="text-muted text-sm">Not done: the assistant stopped before this ran.</p>
                            @else
                                {{-- A new message came before anyone decided: the SDK told the model it wasn't approved. --}}
                                <p class="text-muted text-sm">Not done: the conversation moved on before anyone decided.</p>
                            @endif
                        </div>
                    </x-widget.chat.message>
                @endforeach

                @if (filled($message->content))
                    {{-- markdown: formatted, with any HTML the model wrote dropped. --}}
                    <x-widget.chat.message wire:key="message-{{ $message->id }}" name="Support assistant" initials="AI" :time="$message->created_at" :markdown="$message->content" />
                @elseif ($message->status->value === 'failed')
                    {{-- The reply failed, not necessarily the actions: a refund can be made before the model's next call
                         fails. The cards above say which were. --}}
                    <x-widget.chat.message wire:key="message-{{ $message->id }}" name="Support assistant" initials="AI" text="My reply didn't come through. The actions above show what was done: check them before asking again." />
                @endif
            @endif
        @endforeach

        {{-- While the model works: after a message, and after a decision. --}}
        <div wire:loading.flex wire:target="send, decide">
            <x-widget.chat.typing name="Support assistant" />
        </div>

        <x-slot:composer>
            <x-widget.chat.composer wire:submit="send" wire:model="prompt" placeholder="Ask about an order…" maxlength="2000" />
        </x-slot:composer>
    </x-widget.chat>
</div>

The route lets in only people allowed to refund, through a gate you define, for example Gate::define('refund-orders', fn (User $user) => $user->is_support):

routes/web.php
// routes/web.php
use App\Livewire\SupportAssistant;

// Only people who may refund get an assistant that can. The component checks the conversation is theirs.
Route::get('/assistant/{conversation?}', SupportAssistant::class)
    ->middleware(['auth', 'can:refund-orders'])
    ->name('assistant');

Before you ship it

  • An audit trail. The SDK fires ToolApprovalRequested when a run pauses and ToolApprovalResolved when it carries on, with each result and whether it was denied. Listen for them to log who decided what.
  • A new message instead of a decision. It counts as a no: the SDK tells the model the action wasn't approved, and the card says so.
  • Testing. With the agent faked (SupportAgent::fake()), a resume doesn't take the SDK's real path and the approval events don't fire. Test the tool's needsApproval() and handle() directly, and the component's checks with the agent faked.
  • Time. A decision calls the model again, so it takes as long as a reply. The component allows 150 seconds; check your web server's timeout too. To show the reply word by word, see the AI chat guide: the SDK's stream() takes decisions as well.
  • Other tools. The card reads a refund's arguments. For a second tool that needs approval, give it its own card, chosen by $call['name'].