Build an AI-Powered Admin Dashboard in Vue That Lets Agents Analyze Data and Perform Actions Safely

The first version of the dashboard had a chat box. The second version had a confirmation modal. The third version had an audit log.

Only the third one survived contact with real admins.

The problem with the first two wasn’t the model. It was that both treated the dashboard as a conversation with a safety prompt attached. A chat box makes every action feel like a message. A confirmation modal makes every approval feel like a speed bump. Neither gives an admin the thing they actually need: a durable, scoped, auditable decision surface where the agent’s analysis and the agent’s proposed actions are visible before anything executes.

An AI-powered admin dashboard is not a chat UI with a sidebar. It is a supervision console. The moment the agent can issue a refund, change a plan, or delete a record, the interface stops being a conversation and becomes an authorization surface. Vue renders that surface. Laravel owns the decision. The tool layer executes only what the decision permits.

📋 Table of Contents

1. The Chat Box Is the Wrong Primitive

Scenario:
An admin opens the dashboard and asks the agent to “find accounts with declining usage and propose a retention offer.” The agent answers in a paragraph, lists five accounts, and suggests a discount. The admin reads it, scrolls up, loses the list, and asks again. When the agent proposes a credit for one of the accounts, a modal appears. The admin clicks approve because the paragraph sounded reasonable. Three weeks later, the credit is flagged in finance because the account was already on a custom plan.

Why it matters:
A chat transcript is chronological, not decision-oriented. It optimizes for reading, not for verifying or acting. Approvals buried in a conversation are hard to audit, easy to rubber-stamp, and impossible to resume after a page reload. The chat box also hides the agent’s tool calls, which are the exact moments where supervision matters.

Solution:
Build the dashboard as three regions: a conversation panel for intent, an analysis canvas for structured results, and an action queue for pending approvals. The conversation is where the admin asks. The canvas is where the agent’s output becomes tables, charts, diffs, and typed records. The action queue is where decisions happen.

<script setup>
import { ref } from 'vue'
import ConversationPanel from './ConversationPanel.vue'
import AnalysisCanvas from './AnalysisCanvas.vue'
import ActionQueue from './ActionQueue.vue'

const runId = ref(null)
const analysis = ref(null)
const pendingActions = ref([])
</script>

<template>
  <div class="grid grid-cols-12 h-screen">
    <ConversationPanel
      class="col-span-4"
      :run-id="runId"
      @analysis="analysis = $event"
      @pending="pendingActions = $event"
    />
    <AnalysisCanvas class="col-span-5" :analysis="analysis" />
    <ActionQueue class="col-span-3" :actions="pendingActions" />
  </div>
</template>

Why this works:
The layout separates three different cognitive tasks. Reading the conversation is low-stakes. Reading the analysis is where the admin verifies the agent’s reasoning. Approving an action is where the admin takes responsibility. Mixing them into one scrolling transcript collapses those tasks into a single “looks fine” gesture.

🧠 The important part: The action queue is not a notification center. It is the place where the human decision is recorded. If the approval only exists inside the chat transcript, it does not exist.

2. Read, Analyze, and Act Are Three Different Permissions

Scenario:
The agent has twelve tools. Every tool is marked as requiring approval because “AI safety.” The admin approves a read-only customer lookup, then approves a search query, then approves a chart generation, then stops reading the modals and approves the refund without looking.

Why it matters:
Uniform approval is worse than selective approval, because it trains the reviewer to click through. The gate that matters is the one on the action that can’t be undone. If every tool gets the same treatment, the important gate is indistinguishable from the noise.

Solution:
Classify tools into three tiers. Read tools run inline and are logged. Analyze tools run inline but their structured output is rendered for review. Act tools pause for approval, with the gate calibrated to the argument, not just the tool name.

Laravel AI SDK v1’s approval API is designed for this. Approval is opt-in per tool: implement the Approvable contract and use the InteractsWithApprovals trait, and the tool requires approval by default. But the more useful pattern is per-argument approval. The needsApproval method returns a boolean or an Approval instance carrying a reason, so a small refund can run inline while a large one pauses.

namespace App\Ai\Tools;

use App\Models\Order;
use Laravel\Ai\Approvals\Approval;
use Laravel\Ai\Concerns\InteractsWithApprovals;
use Laravel\Ai\Contracts\Approvable;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\ToolInput;
use Laravel\Ai\ToolOutput;

class IssueRefund implements Approvable, Tool
{
    use InteractsWithApprovals;

    public function description(): string
    {
        return 'Issue a refund for an order. Use this only after confirming the order ID and the amount with the customer.';
    }

    protected function needsApproval(ToolInput $input): Approval|bool
    {
        return $input->amount <= 2000
            ? false
            : Approval::required('Refunds over $2,000 need a finance reviewer.');
    }

    public function handle(ToolInput $input): ToolOutput
    {
        $order = Order::findOrFail($input->order_id);
        $order->refund($input->amount);

        return ToolOutput::text("Refunded {$input->amount} on order {$order->id}.");
    }
}

At registration, the agent can override the default for individual tools using withoutApproval() or requireApproval():

public function tools(): iterable
{
    return [
        (new LookUpOrder)->withoutApproval(),
        (new SearchKnowledgeBase)->withoutApproval(),
        (new IssueRefund)->requireApproval('Every refund is reviewed.'),
    ];
}

Why this works:
The gate is proportional to the risk. Read tools don’t interrupt the flow. Analyze tools produce a reviewable artifact. Act tools pause only when the arguments cross a threshold. The reviewer’s attention is reserved for the decisions that need it.

⚠️ Gotcha: Approval fatigue is a security bug, not a UX annoyance. If the queue is full of low-risk approvals, the high-risk one gets approved by reflex. Tune the thresholds until the queue contains only decisions a reasonable reviewer would want to see.

3. Structured Output Turns Analysis Into UI

Scenario:
The agent returns a paragraph: “Three accounts are at risk. Acme is down 40%, Globex is down 32%, and Initech has not logged in for 21 days.” The admin needs a table, a sort control, and a link to each account. The paragraph is readable and unusable.

Why it matters:
Free-form text is not a data contract. Vue can’t render a chart from prose, and the admin can’t sort or filter a sentence. More importantly, the dashboard can’t verify that the agent’s analysis is internally consistent, because there’s no structure to validate.

Solution:
Use Laravel AI SDK’s structured output to force the analysis into a schema. Implement HasStructuredOutput and define a schema(JsonSchema $schema) method; the response is validated against that schema and can be accessed like an array instead of parsed from text.

namespace App\Ai\Agents;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;

class ChurnAnalyst implements Agent, HasStructuredOutput
{
    use Promptable;

    public function instructions(): string
    {
        return 'Analyze account usage data and identify accounts at risk of churning. Return only the structured result.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'accounts' => $schema->array()
                ->items($schema->object([
                    'account_id' => $schema->string()->required(),
                    'name' => $schema->string()->required(),
                    'usage_drop_percent' => $schema->number()->min(0)->max(100)->required(),
                    'days_since_login' => $schema->integer()->min(0)->required(),
                    'risk' => $schema->string()->enum(['low', 'medium', 'high'])->required(),
                    'recommended_action' => $schema->string()->required(),
                ]))
                ->required(),
            'summary' => $schema->string()->required(),
        ];
    }
}

Vue renders the typed result instead of parsing text:

<script setup>
defineProps({ analysis: Object })
</script>

<template>
  <section v-if="analysis">
    <p class="text-sm text-gray-600">{{ analysis.summary }}</p>

    <table class="w-full mt-4">
      <thead>
        <tr>
          <th>Account</th>
          <th>Usage drop</th>
          <th>Risk</th>
          <th>Recommended</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="account in analysis.accounts" :key="account.account_id">
          <td>{{ account.name }}</td>
          <td>{{ account.usage_drop_percent }}%</td>
          <td><RiskBadge :level="account.risk" /></td>
          <td>{{ account.recommended_action }}</td>
        </tr>
      </tbody>
    </table>
  </section>
</template>

Why this works:
The schema is a contract between the agent and the dashboard. The UI can render a table, a badge, and a sort control because the fields exist and their types are guaranteed. The backend can validate that every account has a risk level and a recommended action before the admin ever sees it.

💡 Practical note: Keep the schema narrow. A data: object field with no properties defeats the purpose. Every field the UI relies on should be a named property with a type, and every enum should be closed so the model can’t invent a risk level the UI doesn’t know how to render.

4. The Approval Lives in a Row, Not a Prompt

Scenario:
The agent proposes a refund. A modal appears. The admin’s browser crashes. When they reopen the dashboard, the modal is gone, the refund is still pending, and no one knows it exists. A queue worker restart has the same effect on any approval held in memory.

Why it matters:
A pause held in a stack frame dies with the process. A prompt instruction is a suggestion to a stochastic model, not a permission system. If the approval is not persisted, it is not durable, and a non-durable approval is not a control.

Solution:
Model the approval as a database row with an explicit state machine. The model’s proposed action and the human’s decision are both persisted before anything executes. The unique tool call ID prevents duplicate proposals from the same model call.

Schema::create('action_approvals', function (Blueprint $table) {
    $table->ulid('id')->primary();
    $table->foreignUlid('conversation_id')->index();
    $table->string('tool_call_id')->unique();
    $table->string('tool_name');
    $table->json('arguments');
    $table->string('arguments_hash', 64);
    $table->string('status')->default('pending');
    $table->string('reason')->nullable();
    $table->foreignId('decided_by')->nullable()->constrained('users');
    $table->timestamp('decided_at')->nullable();
    $table->timestamp('expires_at')->index();
    $table->timestamp('consumed_at')->nullable();
    $table->timestamps();
});
enum ApprovalStatus: string
{
    case Pending = 'pending';
    case Approved = 'approved';
    case Rejected = 'rejected';
    case Expired = 'expired';
    case Consumed = 'consumed';
}

When the model calls an approvable tool, the SDK stops short of executing it and returns the pending calls on the response. Each pending approval carries the tool call ID, tool name, arguments, and reason. Persist them:

$response = (new SupportAgent)
    ->forUser($user)
    ->prompt('Refund the damaged headphones on order 4192.');

if ($response->hasPendingApprovals()) {
    foreach ($response->pendingApprovals as $approval) {
        ActionApproval::create([
            'conversation_id' => $response->conversationId,
            'tool_call_id' => $approval->id,
            'tool_name' => $approval->tool,
            'arguments' => $approval->arguments,
            'arguments_hash' => hash('sha256', json_encode($approval->arguments)),
            'reason' => $approval->reason,
            'expires_at' => now()->addHours(24),
        ]);
    }
}

Why this works:
The approval survives page reloads, worker restarts, and deploys. The action queue reads from the table, not from a transient event. The unique tool_call_id makes the proposal idempotent. The expiry closes the window for stale decisions.

🚨 Production warning: Never let the agent’s own process render the approval UI and then treat the click as authorization. If the same process that proposed the action also records the approval, the model’s planner influenced the decision surface. The approval record must be written by your application, after an authenticated human action, and read by the executor as the permission to proceed.

5. Two Channels: Stream for Progress, REST for Decisions

Scenario:
The approval card is rendered inside the SSE stream. The admin reloads the page. The stream reconnects, but the approval card is gone because it was a transient event. The pending refund sits in limbo.

Why it matters:
A chat stream is an event log, not a work queue. Approvals need an inbox that survives reloads and can be acted on from anywhere. If the only way to approve something is to scroll back through a conversation, you’ve built a notification, not a workflow.

Solution:
Use two channels. SSE carries progress and tokens, including tool_approval_request events during streaming. A REST endpoint returns the pending approval inbox. The decision itself is a POST, not a stream event.

On the Laravel side, the stream route emits typed events as the agent runs:

public function stream(AgentRun $run): StreamedResponse
{
    return response()->stream(function () use ($run) {
        foreach ($this->orchestrator->run($run) as $event) {
            echo "event: {$event['event']}\n";
            echo 'data: ' . json_encode($event['data']) . "\n\n";
            ob_flush();
            flush();
        }
    }, 200, [
        'Content-Type' => 'text/event-stream',
        'Cache-Control' => 'no-cache',
        'X-Accel-Buffering' => 'no',
    ]);
}

On the Vue side, a composable owns the stream and the approval inbox separately:

// composables/useAgentDashboard.js
import { ref, onUnmounted } from 'vue'

export function useAgentDashboard(runId) {
  const tokens = ref('')
  const status = ref('idle')
  const approvals = ref([])
  let source = null

  async function loadApprovals() {
    const res = await fetch(`/api/runs/${runId}/approvals`)
    approvals.value = await res.json()
  }

  async function decide(approvalId, decision) {
    const res = await fetch(`/api/approvals/${approvalId}/decision`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
      },
      body: JSON.stringify({ decision }),
    })

    if (!res.ok) {
      status.value = 'decision_failed'
      return
    }

    approvals.value = approvals.value.filter(a => a.id !== approvalId)
  }

  function connect() {
    loadApprovals()
    source = new EventSource(`/api/runs/${runId}/events`)

    source.addEventListener('token', (e) => {
      tokens.value += JSON.parse(e.data).text
    })

    source.addEventListener('tool_approval_request', (e) => {
      approvals.value.push(JSON.parse(e.data))
      status.value = 'awaiting_approval'
    })

    source.addEventListener('completed', () => {
      status.value = 'done'
      source.close()
    })
  }

  onUnmounted(() => source?.close())

  return { tokens, status, approvals, connect, decide }
}

Why this works:
The stream can drop and reconnect without losing the approval, because the inbox refetches from the database on mount. The decision is an explicit POST with CSRF protection. The backend validates that the approval is still pending, that the user is allowed to decide it, and that the arguments hash matches.

⚠️ Gotcha: Native EventSource only supports GET. If you need POST-based SSE, use fetch with a ReadableStream and implement reconnection yourself. The two-channel design avoids this entirely: POST creates the run, EventSource follows the run’s events, and the approval inbox is a separate REST call.

6. Bind the Approval to the Exact Action

Scenario:
A manager approves a $200 refund. Between the approval and the execution, the agent retries the tool with a different amount, or a stale callback replays the approval against a different order. The executor sees “approved” and runs it.

Why it matters:
An approval that authorizes a tool name is not an approval of an action. It authorizes anything that tool might do. The human approved a specific amount, a specific order, and a specific reason. If any of those change, the approval is stale and must not be honored.

Solution:
Hash the canonical action at proposal time. Store the hash with the approval. Recompute it at execution time and reject any mismatch. Add an expiry and a single-use consumption marker under a row lock.

final class ApprovalEnvelope
{
    public function __construct(
        public readonly string $tool,
        public readonly array $arguments,
        public readonly string $resource,
        public readonly string $expiresAt,
    ) {}

    public function hash(): string
    {
        return hash('sha256', json_encode([
            'tool' => $this->tool,
            'arguments' => $this->arguments,
            'resource' => $this->resource,
            'expires_at' => $this->expiresAt,
        ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
    }
}

At execution:

DB::transaction(function () use ($approval) {
    $locked = ActionApproval::whereKey($approval->id)
        ->where('status', ApprovalStatus::Approved)
        ->where('expires_at', '>', now())
        ->lockForUpdate()
        ->firstOrFail();

    $currentHash = (new ApprovalEnvelope(
        tool: $locked->tool_name,
        arguments: $locked->arguments,
        resource: $locked->arguments['order_id'],
        expiresAt: $locked->expires_at->toIso8601String(),
    ))->hash();

    if (! hash_equals($locked->arguments_hash, $currentHash)) {
        throw new ApprovalPayloadChanged();
    }

    $locked->update([
        'status' => ApprovalStatus::Consumed,
        'consumed_at' => now(),
    ]);

    $this->execute($locked);
});

Why this works:
The hash binds the approval to the action the human saw. The expiry limits the replay window. The lockForUpdate plus the status check makes consumption atomic, so two workers can’t both execute the same approval. The unique constraint on tool_call_id prevents duplicate proposals from the same model call.

🔍 Why this matters: The security principle is the same one payment systems use for strong customer authentication: any change to the amount or the payee invalidates the authentication. Substitute tool, arguments, and resource for amount and payee, and you have the rule for agent approvals.

7. Scope the Agent’s Credentials to the Tenant and the Task

Scenario:
The agent runs with a service account that has broad database access. A prompt injection in a retrieved document tells it to “look up the account for a different tenant.” The tool executes because the service account can read every row. The model was never authorized to see that data, but the tool was.

Why it matters:
Tool-level authentication is not user-level authorization. If the agent executes with a superuser credential, the model’s prompt is the only thing standing between the user and every tenant’s data. A prompt is not an authorization layer. The model has no reliable concept of identity, and it can be manipulated by the content it reads.

Solution:
Resolve tenant and user identity from the authenticated session, never from the prompt. Pass that context to every tool. If the tool executes against a database, scope the query by tenant in code. If the tool calls an external MCP server, mint a short-lived, audience-restricted token rather than forwarding the user’s session token to every downstream service.

final class TenantScopedRetriever
{
    public function __construct(private TenantContext $tenant) {}

    public function retrieve(string $query, int $limit = 5): Collection
    {
        return KnowledgeChunk::query()
            ->where('tenant_id', $this->tenant->id())
            ->whereVectorSimilarTo('embedding', $query, minSimilarity: 0.35)
            ->limit($limit)
            ->get();
    }
}

For MCP tools, the client should send a token scoped to the tool server, not the user’s full credential:

public function tools(): array
{
    return [
        ...Mcp::client('billing')
            ->withToken($this->scopedToken('billing:read'))
            ->tools(),
    ];
}

Why this works:
The tenant boundary is enforced in code, before the model sees any content. The model can propose a tool call, but the tool can only read what the scoped credential allows. A prompt injection can influence the proposal; it cannot widen the credential.

🚨 Production warning: OAuth token passthrough is a common anti-pattern. If your dashboard forwards the admin’s token to every MCP server the agent connects to, a compromised server can replay it against other services. Validate token audience, use short-lived scoped tokens, and never give a tool server credentials it doesn’t need.

8. The Tool Schema Is the Safety Contract

Scenario:
The agent calls an update_order tool with a free-form data object. The model includes a field called refunded_at that the tool interprets as “issue a refund.” The tool executes. No approval was requested, because the tool wasn’t marked as an approval tool.

Why it matters:
Flexible schemas let the model invent fields, and invented fields can trigger unintended behavior. The schema is the contract that constrains what the model can propose. A wide schema is a wide attack surface.

Solution:
Make the schema narrow. Use closed enums, required fields, and explicit types. Do not accept raw SQL, free-form JSON, or a data bag that the tool interprets dynamically. If an action is dangerous, it should be a separate tool with its own approval gate, not a field inside a general-purpose update tool.

Before:

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

After:

public function schema(JsonSchema $schema): array
{
    return [
        'order_id' => $schema->string()->required(),
        'action' => $schema->string()
            ->enum(['mark_shipped', 'cancel', 'update_address'])
            ->required(),
        'address' => $schema->string()->nullable(),
    ];
}

Why this works:
The model can only propose the three actions the schema allows. There is no refunded_at field to invent. The address field is nullable and only meaningful for update_address, which the tool can validate. The approval policy can gate cancel without gating mark_shipped.

🧠 The important part: ToolApprovalGuard inspects the tool calls an agent proposes while pausing for approval, before those calls are surfaced for review. But it explicitly cannot inspect tool results, retrieved documents, or conversation history that may have influenced them. The schema is the part of the safety contract that the middleware can actually enforce.

9. What Breaks at Fifty Concurrent Approvals

Scenario:
A bulk import triggers 50 agent runs. Each one proposes a write. The action queue fills. Two admins open the same approval. One approves, the other rejects. The executor runs the refund twice because a retry replayed the callback.

Why it matters:
Approval systems fail at concurrency, not at the single-user demo. Duplicate decisions, double execution, expired approvals, and queue fan-out are the production problems. They’re all solvable, but only if you design for them before the first bulk run.

Solution:
Treat the approval as a lease with a terminal state, and make every execution path idempotent.

FailureControl
Two admins decide at onceAtomic UPDATE ... WHERE status = 'pending'; second decision gets a 409
Executor retries after timeoutIdempotency key = approval ID; execution ID stored and checked
Approval expires while queuedexpires_at checked inside the execution transaction
SSE stream dropsInbox refetches from REST on reconnect
Queue callback arrives twiceApproval consumed once under a row lock
Inbox becomes unusableFilter by status, tenant, risk, and assignee; virtualize the list

The decision endpoint should be a single conditional update:

$updated = ActionApproval::whereKey($approvalId)
    ->where('status', ApprovalStatus::Pending)
    ->where('expires_at', '>', now())
    ->update([
        'status' => $decision === 'approve'
            ? ApprovalStatus::Approved
            : ApprovalStatus::Rejected,
        'decided_by' => $user->id,
        'decided_at' => now(),
    ]);

abort_if($updated === 0, 409, 'Approval already resolved or expired.');

Why this works:
The conditional update makes the decision atomic. A second admin gets a 409 instead of overwriting the first decision. The expiry check inside the transaction prevents a stale approval from being honored after the deadline. The idempotency key prevents a replayed execution from charging twice.

💡 Practical note: Set the approval expiry shorter than the execution lease. If the approval expires first, the admin sees a clear “this request expired” state and the workflow can be cancelled deliberately. If the execution lease expires first, you have an ambiguous state that’s harder to reconcile.

10. Test the Gate, Not Just the Chat

Scenario:
The test suite asserts that the agent answers a refund question. It does not assert that the refund tool pauses, that a rejected approval does not execute, or that a changed argument invalidates the approval. The gate is the security boundary, and it has no tests.

Why it matters:
The chat is the visible feature. The gate is the invisible one. If the gate regresses, the tests still pass because they only check the answer. The failure surfaces in production, after a refund has already been issued.

Solution:
Write adversarial tests for the approval lifecycle. Fake the model provider, but do not fake the gate. The Laravel AI SDK ships fakes for agents and tool calls, so you can assert behavior without hitting a provider.

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

it('pauses before executing an approvable refund', function () {
    $user = User::factory()->create();
    $order = Order::factory()->create(['total' => 2500]);

    $response = (new SupportAgent)
        ->forUser($user)
        ->prompt("Refund order {$order->id}.");

    expect($response->hasPendingApprovals())->toBeTrue();
    expect($order->fresh()->refunded_at)->toBeNull();
});

it('does not execute a rejected refund', function () {
    $response = (new SupportAgent)
        ->forUser($user)
        ->prompt("Refund order {$order->id}.");

    $pending = $response->pendingApprovals->first();

    (new SupportAgent)
        ->continue($response->conversationId, as: $user)
        ->prompt(Decisions::from([
            $pending->id => Decision::reject('Outside the return window.'),
        ]));

    expect($order->fresh()->refunded_at)->toBeNull();
});

Test the mismatch case too. If the approval is for one amount and the execution receives another, the hash check should throw:

it('rejects an approval whose payload changed', function () {
    $approval = ActionApproval::factory()->approved()->create([
        'tool_name' => 'issue_refund',
        'arguments' => ['order_id' => 'ord_1', 'amount' => 200],
    ]);

    $approval->arguments = ['order_id' => 'ord_1', 'amount' => 900];

    expect(fn () => $this->executor->execute($approval))
        ->toThrow(ApprovalPayloadChanged::class);
});

Why this works:
The tests assert the things that matter: the agent paused, the rejected action didn’t execute, and the changed payload was refused. The provider is faked, so the tests are fast and deterministic. The gate is real, so the tests actually cover the security boundary.

🔍 Why this matters: A test that only checks the final answer is a test of the model, not the system. The system’s safety property is “no action executes without a valid, current, human decision.” That property needs its own tests, independent of whatever the model happens to generate.

The Decision Guide: Which Actions Deserve a Human

Not every action needs a gate. A dashboard that pauses for everything is a dashboard that teaches admins to click through. The useful question is not “is this an AI action?” but “what happens if this action is wrong, and how fast can it be undone?”

Action classExamplesGateWhy
ReadLook up an order, search the knowledge base, fetch a customer recordNone; log the callReversible, no side effects
AnalyzeGenerate a churn report, summarize a ticket, classify riskNone; render the structured outputThe output is reviewed before any action
Low-risk writeAdd an internal note, tag a record, mark a shipmentInline, with an undo windowReversible, low blast radius
High-risk writeRefund over a threshold, change a plan, cancel a subscriptionApproval with the exact arguments and a reasonExpensive or disruptive if wrong
IrreversibleDelete an account, drop a table, send an external emailApproval plus a second factor or a second reviewerCannot be undone

The implementation order I’d follow:

First, build the action queue and the approval row. Before the agent has any write tools, build the table, the state machine, the inbox, and the decision endpoint. The gate is the foundation, not an add-on.

Second, add structured output to the analysis agent. The dashboard needs typed data before it can render anything useful. Start with one schema and one canvas component.

Third, add the first write tool behind the gate. Pick a low-risk write, wire needsApproval, and verify that the pause survives a page reload and a worker restart.

Fourth, add the hash binding and the expiry. The gate is not real until the approval is bound to the exact action and cannot be replayed after the deadline.

Fifth, add the tenant and credential scoping. A gate on top of a superuser credential is a gate on the wrong thing. Scope the tool’s access before you scale the agent’s capabilities.

Sixth, add the adversarial tests. The gate will regress. The tests are how you find out before a customer does.

The final observation is the one that took the longest to internalize: the dashboard’s job is not to make the agent look smart. It’s to make the human’s decision fast, informed, and auditable. The agent can propose anything. The dashboard shows the proposal, the evidence, and the exact action. The human decides. Laravel records the decision. The executor honors only a current, bound, single-use approval.

That’s the difference between a chat box with a confirmation modal and a supervision console. The chat box asks the admin to trust the model. The console asks the admin to review an action. Only one of those survives an audit.

Post a Comment

Previous Post Next Post