Originally published at hafiz.dev
Your support agent has a refund tool. A customer sends a message that sounds close enough to a refund request, the model decides that's what it wants, and four thousand euros leaves your Stripe account before a human reads a single word of the conversation.
Until 21 July there was no framework-level way to stop that. Once a Laravel AI SDK agent started its tool loop, it ran to the end on its own. You could validate arguments inside handle(). You could build allowlists. You could keep the dangerous tools out of the agent entirely and accept that your agent is now read-only. What you couldn't do was put a person in the middle of the loop.
laravel/ai v0.10.0 changed that. The human-in-the-loop API pauses the agent before an approvable tool executes, hands the pending call back to you, and waits. The Laravel team announced it at Laracon US, the code landed in laravel/ai#773, and the docs cover the happy path well.
This post assumes you've already got an agent running. If you haven't, start with building your first Laravel AI SDK assistant and come back.
The happy path isn't the interesting part. The interesting part is what happens when generation fails halfway through, when two tools land in the same step, and when the person clicking "approve" isn't the person who owns the conversation.
What actually shipped
Approval is opt-in, per tool. A tool becomes approvable when it implements the Approvable contract and uses the InteractsWithApprovals trait. That's the whole opt-in:
<?php
namespace App\Ai\Tools;
use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Concerns\InteractsWithApprovals;
use Laravel\Ai\Contracts\Approvable;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
class IssueRefund implements Approvable, Tool
{
use InteractsWithApprovals;
public function description(): Stringable|string
{
return 'Refund an amount against a customer order.';
}
public function handle(Request $request): Stringable|string
{
$order = Order::findOrFail($request['order_id']);
$order->refund($request['amount']);
return "Refunded {$request['amount']} on order {$order->id}.";
}
public function schema(JsonSchema $schema): array
{
return [
'order_id' => $schema->integer()->required(),
'amount' => $schema->integer()->required(), // cents
];
}
}
Once that trait is on the class, the tool requires approval every single time. Which is almost never what you want.
Everything else about the tool stays the same. The description, handle, and schema methods work exactly as they do on any other tool, so if you've already built an agent with tools and conversation memory, you're adding two lines to a class you already have.
Approving everything is the wrong default
A tool that asks permission for every call trains people to click approve without reading. That's worse than no approval at all, because now you have an audit trail that says a human reviewed something nobody reviewed.
Gate it on the arguments instead. Define a needsApproval method that returns a boolean, or an Approval instance carrying the reason the model's caller will see:
use Laravel\Ai\Approvals\Approval;
protected function needsApproval(Request $request): Approval|bool
{
return $request['amount'] <= 20000
? false
: Approval::required('Refunds over 200 EUR need a manager.');
}
Amounts are in cents here, so anything up to 200 EUR goes straight through and anything above it stops. The reason string matters more than it looks, because it's what gets rendered next to the approve button, and a reason like "approval required" tells the reviewer nothing about why this specific call is different.
You can also flip the requirement where the agent registers its tools, which is useful when the same tool is safe in one agent and dangerous in another:
public function tools(): iterable
{
return [
(new LookUpOrder)->withoutApproval(),
(new IssueRefund)->requireApproval('Every refund gets reviewed.'),
];
}
The prerequisite buried at the top of the docs
Tool approval needs a Conversational agent whose history is actually persisted. There has to be something to resume from, and an agent that rebuilds its messages from an array in memory has nothing to come back to.
The RemembersConversations trait handles it:
<?php
namespace App\Ai\Agents;
use App\Ai\Tools\IssueRefund;
use App\Ai\Tools\LookUpOrder;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;
class SupportAgent implements Agent, Conversational, HasTools
{
use Promptable, RemembersConversations;
public function instructions(): string
{
return 'You help customers with order and billing questions.';
}
public function tools(): iterable
{
return [
new LookUpOrder,
new IssueRefund,
];
}
}
One trap here predates the approval API and still catches people: if you define a messages method on an agent that uses RemembersConversations, your method wins and the trait never loads history from the database. No conversation, nothing to resume.
Put an approvable tool on an agent that can't be resumed at all and you don't get a silent failure. The SDK throws ApprovalNotResumableException rather than handing back a pause nobody can resolve, which is the right call and saves you a debugging session.
The pause and the resume
When the model calls an approvable tool, the agent stops short of running it. The pending calls come back on the response:
$response = (new SupportAgent)
->forUser($user)
->prompt('Refund the damaged headphones on order 4192.');
if ($response->hasPendingApprovals()) {
foreach ($response->pendingApprovals as $approval) {
// $approval->id
// $approval->tool
// $approval->arguments
// $approval->reason
}
}
To resume, you continue the conversation and pass a Decisions instance keyed by tool call ID:
use Laravel\Ai\Approvals\Decision;
use Laravel\Ai\Approvals\Decisions;
$response = (new SupportAgent)
->continue($conversationId, as: $user)
->prompt(Decisions::from([
'call_abc' => Decision::approve(),
'call_ghi' => Decision::reject('This order is outside the return window.'),
]));
Note what's happening there. The decisions are the prompt. You're not calling some separate approval endpoint on the SDK, you're continuing the same conversation with a different kind of input, which is why the routing for this collapses into one endpoint later.
Booleans work as shorthand for approve and reject. Every pending call needs a decision or you get an ApprovalMismatchException, and that same exception covers IDs you made up and calls that were already resolved. If you don't want to enumerate all of them, set a default for the rest:
$decisions = Decisions::from([
'call_abc' => true,
])->rejectRemaining('Not approved.');
Decision::approveAll() and Decision::rejectAll() cover the blanket cases and return a Decisions instance, so they drop straight into the same prompt call.
There's a meaningful difference between the two kinds of rejection. Decision::reject('reason') sends that string back to the model, which keeps responding and can explain the refusal to the customer. A rejection with no result records the rejection and stops the generation loop right there. Pick deliberately. A silent stop looks like a hung chat window to whoever is on the other end.
The third decision nobody covered
Approve and reject are the two everyone writes about. There's a third:
use Laravel\Ai\Approvals\Decision;
use Laravel\Ai\Approvals\Decisions;
$response = (new SupportAgent)
->continue($conversationId, as: $user)
->prompt(Decisions::from([
'call_abc' => Decision::edit(['order_id' => 4192, 'amount' => 20000]),
]));
Decision::edit() approves the call with replaced arguments. The model asked to refund 4,000 EUR, the manager decides 200 is right, and the tool runs with the corrected amount. No rejection, no second round trip, no asking the customer to explain themselves again.
This is the decision type that makes approval feel like review rather than a gate, and it's the one missing from every writeup of the release so far. One rule to remember: an edit decision has to carry arguments. A bare one throws.
Here's the full round trip:
Three failure modes worth knowing before you ship
The approval you already spent. Laravel records an approved tool's result before it asks the model to continue generating. If generation blows up after that point, the tool already ran. Resubmitting the same decisions throws an ApprovalMismatchException, and the reference HTTP flow turns that into a 409 carrying the current pending approvals so a stale approval screen can refresh itself. Recover by continuing with an ordinary text prompt, never by replaying the decision map. Worth knowing alongside this: the SDK will not fail over to a backup provider on a resume once approved tools have executed, so a provider outage mid-resume surfaces as an error instead of quietly running your refund somewhere else.
Pauses are per call, not per step. If the model requests three tools in one step and only one of them is approvable, the other two run immediately while the third waits. Anything with an external side effect needs to be idempotent, and $request->toolCallId() gives you the key to deduplicate on. This is the part most people will get wrong, because the mental model everyone brings is "the agent stops", and what actually stops is one call.
Streaming and broadcasting need their own handling. Approval works with prompt, stream, queue, broadcast, broadcastNow, and broadcastOnQueue. During streaming a pause arrives as a tool_approval_request event, and under the Vercel AI SDK stream protocol it maps to that protocol's native tool approval parts. Queued agents pass the response to the then callback and dispatch a ToolApprovalRequested event. There's a matching ToolApprovalResolved event too, which is the one you want for your audit log.
Authorization is still your job
There's a sentence in the conversations docs that most people skim: continue() does not verify that the participant you pass actually owns the conversation. Authorizing that is on you.
The approval work narrows what that means. The hardening pass on the pull request added an ownership check before a resume runs any gated tool, so the obvious nightmare, one user approving a refund sitting in someone else's conversation, is closed at the framework level. Good. What isn't closed is everything around it. An unauthorized continue() still reads back conversation history, and that history now includes the arguments of every pending tool call: order IDs, amounts, file paths, whatever your tools take.
So authorize the conversation, every time:
Route::post('/chat/{conversation}', function (Request $request, Conversation $conversation) {
Gate::authorize('view', $conversation);
// ...
})->middleware('auth');
Then go one step past that. The SDK checks ownership, not authority. "Owns this conversation" and "may approve a 4,000 euro refund" are two different questions, and only the first one has a built-in answer. If your reviewers have tiers, inspect the tool name and arguments on the pending approval before you accept a decision, not after. A junior support rep owning the conversation they're working is normal. A junior support rep clearing a four-figure refund usually isn't.
For the coding-agent version of the same question, I wrote about what happens when you hand an AI agent too much authority in a Laravel app a few months back. Different threat model, same root question: what can this thing reach, and who said it could.
Wiring it into one endpoint
The whole thing fits in a single route, because decisions and messages are both just prompts.
-
Validate that you got one or the other, never both. A request carrying a
messageand adecisionsarray at the same time is ambiguous, so make the rules mutually exclusive withrequired_withoutandprohibited_with. -
Map the incoming array into
Decisionobjects. Each key is a tool call ID and each value carries an action plus an optional result string. -
Build the prompt. Either a
Decisionsinstance or the raw message string. -
Continue the conversation with
->continue($conversation->id, as: $request->user())and pass the prompt. -
Return the status. If
hasPendingApprovals()comes back true, handpendingApprovalsto the frontend and let it render the buttons.
$validated = $request->validate([
'message' => ['nullable', 'string', 'required_without:decisions', 'prohibited_with:decisions'],
'decisions' => ['nullable', 'array', 'required_without:message', 'prohibited_with:message'],
'decisions.*.action' => ['required_with:decisions', Rule::in(['approve', 'reject'])],
'decisions.*.result' => ['nullable', 'string'],
]);
$prompt = isset($validated['decisions'])
? Decisions::from(collect($validated['decisions'])->map(
fn (array $decision) => match ($decision['action']) {
'approve' => Decision::approve(),
'reject' => Decision::reject($decision['result'] ?? null),
}
)->all())
: $validated['message'];
$response = (new SupportAgent)
->continue($conversation->id, as: $request->user())
->prompt($prompt);
return [
'status' => $response->hasPendingApprovals() ? 'awaiting_approval' : 'complete',
'message' => $response->text,
'approvals' => $response->pendingApprovals,
];
The frontend posts back a shape like {"decisions": {"call_abc": {"action": "approve"}}}, keyed by the tool call ID it received.
Testing it without touching a provider
This is the part of the release that got no coverage at all, and it's the part that decides whether the feature survives contact with your CI pipeline.
You can fake a response that's awaiting approval:
use Laravel\Ai\Approvals\PendingApproval;
use Laravel\Ai\Responses\AgentResponse;
SupportAgent::fake([
AgentResponse::fakeWithPendingApprovals([
new PendingApproval(
id: 'call_abc',
tool: 'IssueRefund',
arguments: ['order_id' => 4192, 'amount' => 400000],
reason: 'Refunds over 200 EUR need a manager.',
),
]),
]);
$response = (new SupportAgent)->prompt('Refund order 4192.');
expect($response->hasPendingApprovals())->toBeTrue();
And you can assert on the decisions that were submitted:
SupportAgent::assertPrompted(function (AgentPrompt $prompt) {
return $prompt->hasApprovalDecisions()
&& $prompt->approvalDecisions->get('call_abc')->isApproved();
});
That means the interesting cases are all testable without a network call. Does an unauthorized user get blocked before their decision reaches the agent? Does a partial decision set throw? Does a rejection with no result actually stop the loop? Write those before you ship, not after the first incident. If you're setting up the suite, my Pest testing guide covers the structure I'd use here, and Pest 5's test impact analysis will keep these off the runner when you're changing something unrelated.
Upgrading to 0.10
Three breaking changes, two of which you'll actually feel. There's a new nullable approval_state column on the conversation messages table, so publish and run the migrations. Any custom ConversationStore implementation now has to provide a storeApprovalResults() method. And the Agent contract's prompt methods widened from string to accept decisions as well, which is a no-op if your agents use the Promptable trait and a signature change if you implement the contract yourself.
Then there's the behavior change that isn't filed as breaking and will surprise you anyway: the built-in WriteFile, CopyFile, and DeleteFile filesystem tools now require approval by default. If you hand agents a disk with FileStorage::all(), those three start pausing the moment you upgrade. Decide deliberately which ones get withoutApproval() rather than discovering it from a stuck conversation.
Worth saying plainly: this package is at 0.10. It's pre-1.0, the API surface around approvals is young, and the repository has open work on multi-step approval resume. Pin your version rather than floating on dev-main, and read the diff on minor bumps.
My take
Use this on anything that spends money, deletes data, or sends a message to a third party. Don't use it on reads. That line is easy to hold and it survives code review, which is more than you can say for most security policies.
The trade-off is real, though. Every approval gate is a place where a conversation stops and waits for a human who may be asleep, and an agent that pauses six times per session is a worse product than one that pauses once. Threshold-based needsApproval is the answer, and the threshold should come from your actual refund data, not from a number that felt safe on a Tuesday.
I'd also push back gently on the framing that this makes agents "safe". It makes one class of action reviewable. The agent can still be prompt-injected into calling a tool with plausible-looking arguments, and a reviewer skimming ten approvals an hour will wave that through. Approval is a control, not a guarantee. Pair it with argument validation inside handle() and allowlists at the schema level, the way the SDK's own database tool guidance recommends.
The API itself is good. It's small, it's opt-in per tool, it degrades to a normal prompt, and it's testable. That's about as much as you can ask from a first release.
FAQ
Does tool approval work with queued agents?
Yes. It's supported by prompt, stream, queue, broadcast, broadcastNow, and broadcastOnQueue. For queued agents the response arrives in the then callback and Laravel dispatches a ToolApprovalRequested event you can listen for.
What happens if I only decide on some of the pending calls?
You get an ApprovalMismatchException. Every pending call needs a decision. Use approveRemaining() or rejectRemaining() to set a default for the ones you didn't name explicitly.
Can I change the arguments before the tool runs?
Yes. Decision::edit(['amount' => 20000]) approves the call with replaced arguments instead of the ones the model proposed. The edit has to carry arguments; a bare edit decision throws.
Do I need a database for this?
Yes. Approval requires a Conversational agent with persisted history, which means running the AI SDK migrations and using RemembersConversations or your own ConversationStore.
Is one approval gate enough to secure an agent?
No. It reviews one action at one moment. You still want validated tool schemas, allowlisted tables and columns for anything touching the database, and authorization on the conversation itself before a decision is accepted.
Wrapping up
The pattern that matters here isn't the trait or the contract. It's that a dangerous tool call is now a piece of state your application can hold, inspect, authorize, and resolve later, instead of something that either happened or didn't while nobody was watching.
Start with one tool. Pick the one that would ruin your week if it fired wrong, make it approvable, gate it on arguments, and authorize the conversation before you accept a decision. That's an afternoon of work and it changes the risk profile of the whole agent.
Top comments (0)