Laravel Daily's

Human-in-the-Loop AI Agents in Laravel: Approving Tool Calls Before They Run

A human approval brake stopping an AI agent before it performs an irreversible tool call

Autonomous tools make agents useful. They also give agents the ability to change real data.

An agent that can delete a record, refund an order, or send a notification will eventually try. That action might be correct. It might also be based on incomplete context, an ambiguous prompt, or a model mistake.

Human approval adds a brake before the calls that matter. Laravel’s AI SDK lets an agent pause, show the proposed tool call, and resume after a person approves or rejects it.

This is the missing guardrail layer for production agent workflows.

Why autonomous tool calls need a brake

Read-only tools are usually safe to run automatically. A search, database lookup, or document retrieval can inform the agent without changing the system.

Irreversible tools are different. Consider an agent with access to:

  • Delete files or database records
  • Refund payments
  • Send customer notifications
  • Cancel subscriptions
  • Change permissions
  • Trigger deployments

The model should still be able to propose these actions. It should not execute them without a decision from your application or a human operator.

Laravel’s approval flow pauses the agent before the tool runs. Your interface can then display the tool name, arguments, and reason. An authorized user approves, rejects, or edits the decision before the workflow continues.

An AI agent proposing a tool call while a human approval card gates the action

The pieces: Approvable and InteractsWithApprovals

An approvable tool implements the Approvable contract and uses the InteractsWithApprovals trait.

By default, an approvable tool requires approval before execution. Here is a complete DeleteFile tool:

<?php

namespace App\Ai\Tools;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Concerns\InteractsWithApprovals;
use Laravel\Ai\Contracts\Approvable;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

class DeleteFile implements Approvable, Tool
{
    use InteractsWithApprovals;

    public function description(): Stringable|string
    {
        return 'Delete a file from storage.';
    }

    public function handle(Request $request): Stringable|string
    {
        Storage::delete($request['path']);

        return "Deleted [{$request['path']}].";
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'path' => $schema->string()->required(),
        ];
    }
}

The handle method does not need approval logic. Laravel intercepts the call before handle runs.

That separation matters. The tool owns the action. The approval layer owns whether the action may begin.

Laravel is a productive php web framework because these boundaries remain explicit without requiring a custom orchestration system.

Conditional approval

Some calls are safe in one context but sensitive in another. For example, your agent might freely delete temporary files while requiring approval for permanent files.

Define needsApproval on the tool. It receives the tool request and returns either false, true, or an Approval instance with a human-readable reason.

use Laravel\Ai\Approvals\Approval;
use Laravel\Ai\Tools\Request;

protected function needsApproval(Request $request): Approval|bool
{
    return str_starts_with($request['path'], 'temporary/')
        ? false
        : Approval::required('This will permanently delete a file.');
}

A path such as temporary/preview.txt runs immediately. A path such as invoices/2026-04.pdf pauses for review.

Use reasons that describe the consequence. “Requires approval” is less useful than “This will permanently delete a file.” The reason should help the reviewer make a decision without reading the tool implementation.

Overriding approval at the agent level

Approval behavior can also be changed when registering tools with an agent.

This is useful when a tool is generally sensitive, but a particular agent operates inside a trusted workflow. It also lets you make a tool stricter for one agent without changing the tool class.

<?php

namespace App\Ai\Agents;

use App\Ai\Tools\DeleteFile;
use App\Ai\Tools\SendNotification;
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 FileAssistant implements Agent, Conversational, HasTools
{
    use Promptable, RemembersConversations;

    public function instructions(): string
    {
        return 'Help users manage files and notifications. Never invent file paths.';
    }

    public function tools(): iterable
    {
        return [
            (new SendNotification)->withoutApproval(),
            (new DeleteFile)->requireApproval('Deletion review required.'),
        ];
    }
}

withoutApproval() disables the approval gate for that instance. requireApproval() forces approval and supplies a reason.

Keep this decision close to the agent configuration. A read-only support agent and an operations agent may expose the same tool with different risk policies.

The pause and resume flow

Approval requires a conversational agent with persisted conversation history. The agent must remember the original tool call while it waits for a decision.

Implement Conversational and use RemembersConversations. First, publish and run the AI SDK migrations:

php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate

Then prompt the agent for a user:

$response = (new FileAssistant)
    ->forUser($user)
    ->prompt('Delete the old invoice from storage.');

If the model selects an approvable tool, Laravel pauses before calling handle:

if ($response->hasPendingApprovals()) {
    foreach ($response->pendingApprovals as $approval) {
        $approval->id;         // Tool call ID
        $approval->tool;       // Tool name
        $approval->arguments;  // Proposed arguments
        $approval->reason;     // Human-readable reason
    }
}

At this point, no file has been deleted. Store the pending approval or return it to your frontend. The call ID becomes the key used when the workflow resumes.

Resuming with decisions

Resume the persisted conversation with a Decisions object:

use Laravel\Ai\Approvals\Decision;
use Laravel\Ai\Approvals\Decisions;

$response = (new FileAssistant)
    ->continue($conversationId, as: $user)
    ->prompt(Decisions::from([
        'call_abc' => Decision::approve(),
        'call_ghi' => Decision::reject(
            'The invoice must be retained.'
        ),
    ]));

Boolean values are shorthand:

$decisions = Decisions::from([
    'call_abc' => true,
    'call_ghi' => false,
]);

Every pending call needs a decision. If you want a default for all calls that are not listed explicitly, use approveRemaining() or rejectRemaining():

$decisions = Decisions::from([
    'call_abc' => true,
])->rejectRemaining('Not approved.');

Laravel throws an ApprovalMismatchException when decisions contain unknown call IDs, omit pending call IDs, or attempt to resolve calls that have already been resolved.

Treat the IDs as opaque values. Do not reconstruct them or use tool names as substitutes.

A complete REST API approval flow

The following routes support both a normal message and an approval decision payload. Protect the endpoints with authentication and authorize access to the conversation before continuing it.

For token-authenticated clients, Laravel Sanctum provides a straightforward option. Review the Sanctum documentation for API tokens, abilities, and route protection.

<?php

use App\Ai\Agents\FileAssistant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;
use Illuminate\Validation\Rule;
use Laravel\Ai\Approvals\Decision;
use Laravel\Ai\Approvals\Decisions;
use Laravel\Ai\Models\Conversation;

Route::get('/chat/{conversation}', function (
    Request $request,
    Conversation $conversation
) {
    Gate::authorize('view', $conversation);

    return view('chat', [
        'conversation' => $conversation,
    ]);
})->middleware('auth:sanctum');

Route::post('/chat/{conversation}', function (
    Request $request,
    Conversation $conversation
) {
    Gate::authorize('view', $conversation);

    $validated = $request->validate([
        'message' => [
            'nullable',
            'string',
            'required_without:decisions',
            'prohibits:decisions',
        ],
        'decisions' => [
            'nullable',
            'array',
            'required_without:message',
            'prohibits: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 FileAssistant)
        ->continue($conversation->id, as: $request->user())
        ->prompt($prompt);

    return response()->json([
        'conversation_id' => $response->conversationId,
        'status' => $response->hasPendingApprovals()
            ? 'awaiting_approval'
            : 'complete',
        'message' => $response->text,
        'approvals' => $response->pendingApprovals,
    ]);
})->middleware('auth:sanctum');

A client sends a normal message like this:

{
  "message": "Delete the old invoice."
}

When the API responds with awaiting_approval, the client renders the pending calls and sends a decisions payload:

{
  "decisions": {
    "call_abc": {
      "action": "approve"
    },
    "call_def": {
      "action": "reject",
      "result": "The invoice must be retained."
    }
  }
}

The Gate::authorize call is important. The AI SDK can continue a conversation, but your application must decide whether the current user owns or may view that conversation.

This is also a practical pattern when you want to build rest api with php for a Vue, React, mobile, or internal operations client.

Streaming and queued agents

Approval works with synchronous prompts, streaming, queueing, broadcasting, and queued broadcasting.

During streaming or broadcasting, Laravel emits a tool_approval_request event when execution pauses. If you use the Vercel AI SDK stream protocol, approval requests and results use the protocol’s native tool approval parts:

Route::get('/chat/{conversation}/stream', function (
    Request $request,
    Conversation $conversation
) {
    Gate::authorize('view', $conversation);

    return (new FileAssistant)
        ->continue($conversation->id, as: $request->user())
        ->stream('Review the pending file operation.')
        ->usingVercelDataProtocol();
})->middleware('auth:sanctum');

Queued agents pass the resulting response to the then callback:

use Laravel\Ai\Responses\AgentResponse;

(new FileAssistant)
    ->forUser($user)
    ->queue('Delete the old invoice.')
    ->then(function (AgentResponse $response) {
        if ($response->hasPendingApprovals()) {
            // Notify an operator or persist the approval request.
        }
    });

Laravel also dispatches the ToolApprovalRequested event. Listen for it when approval requests must enter a separate operations queue, notification system, or audit table.

A Laravel REST API approval dashboard connected to an AI chat workflow

The gotcha worth knowing

Laravel stores an approved tool’s result before asking the model to continue.

That means the approval is already resolved if generation fails afterward. Do not submit the same Decisions object again. The tool may have already run, and resubmitting the decision can produce an ApprovalMismatchException.

Instead, continue the conversation with a normal text prompt:

$response = (new FileAssistant)
    ->continue($conversationId, as: $user)
    ->prompt('Continue from the result of the approved file operation.');

Design irreversible tools to be idempotent where possible. Also log the tool call ID, arguments, decision, user, and execution result.

Testing approval workflows

The AI SDK can fake a response that contains pending approvals. This lets you test the UI and continuation path without calling a provider.

use Laravel\Ai\Approvals\PendingApproval;
use Laravel\Ai\Responses\AgentResponse;

FileAssistant::fake([
    AgentResponse::fakeWithPendingApprovals([
        new PendingApproval(
            id: 'call_abc',
            tool: 'DeleteFile',
            arguments: ['path' => 'invoice.pdf'],
            reason: 'This will permanently delete a file.',
        ),
    ]),
]);

$response = (new FileAssistant)
    ->prompt('Delete the invoice.');

expect($response->hasPendingApprovals())->toBeTrue();

You can also assert that the continuation included the expected decision:

use Laravel\Ai\Approvals\Decisions;

FileAssistant::fake();

(new FileAssistant)->prompt(
    Decisions::from([
        'call_abc' => true,
    ])
);

FileAssistant::assertPrompted(function ($prompt) {
    return $prompt->hasApprovalDecisions()
        && $prompt->approvalDecisions
            ->get('call_abc')
            ->isApproved();
});

For broader coverage, test missing IDs, unknown IDs, rejected calls with reasons, and the case where safe tools run while sensitive tools wait.

A developer testing and monitoring pending AI approvals in a Laravel workflow

Production practice

Approve irreversible calls. Auto-approve safe ones.

Log every decision with enough context to reconstruct what happened. Use authorization policies in addition to tool approval. Sanctum abilities can restrict which clients may reach an approval endpoint, while policies can verify ownership and user permissions.

Finally, use Laravel Nightwatch to watch the flow in production. Monitor approval pauses, tool failures, rejected actions, provider errors, and unusual execution patterns.

The goal is not to remove autonomy. It is to place human attention where mistakes are expensive, while letting routine work continue automatically.

Previous
View Transitions in Inertia 3.x: Silky Page Animations for Your Laravel + Vue SPA
Next
Building a Recommendation Engine in Laravel: Embeddings, Vector Search, and the AI SDK