The first multi-agent workflow you build will look like a router. The second will look like a mesh. The third will make you wish you had built a policy engine instead.
Here's how it usually goes. A support API gets an AI assistant. One agent handles everything: it reads the ticket, looks up the order, checks the refund policy, and decides whether to issue a credit. It works until the refund amount crosses a threshold a human should review. You add an approval step. Then the agent needs to hand off billing questions to a specialist, and the specialist needs its own tools and its own model. You add a sub-agent. Then a tool that writes to the production database gets called with an argument the agent invented, and you realize the only thing standing between the model and your data is a natural-language description.
Laravel AI SDK 1.0 gives you the primitives to build all of this: agents, tools, sub-agents, multi-agent patterns, and an Approvable contract that pauses execution before a sensitive tool runs. Laravel MCP 1.0 gives you a standard way to expose your API as tools that any AI client can call. But the primitives are not the architecture. The architecture is the set of decisions about what the agent can see, what it can do without asking, and what must never be automated.
This is the system that survives production: MCP tools with scoped contracts, multi-agent workflows that delegate without losing control, and approval gates that are bound to the exact action, not the tool name.
📋 Table of Contents
- 1. Why Wrapping Your API Endpoints as Tools Fails
- 2. MCP Tool Contracts: One Job, Typed Result, Scoped Identity
- 3. The Multi-Agent Patterns That Actually Map to Business Workflows
- 4. Sub-Agents as Tools: Delegation Without Context Leakage
- 5. Human Approval: Binding the Gate to the Exact Call
- 6. Conversation State and the Streaming Contract
- 7. MCP Security: Tool Poisoning, OAuth, and the Catalog Hash
- 8. The Orchestrator Agent: Routing, Budgets, and Kill Switches
- 9. Testing Agents Without Burning Tokens
- The Approval Matrix: What to Automate and What to Escalate
1. Why Wrapping Your API Endpoints as Tools Fails
Scenario: You have a Laravel API with GET /orders/{id}, POST /refunds, and PATCH /users/{id}. You expose each route as an MCP tool with the same input schema as the HTTP request. The agent can now call getOrder, createRefund, and updateUser. It feels like the API is AI-powered. It's actually an unauthenticated proxy with a natural-language front door.
The first problem is authorization. An HTTP endpoint gets the authenticated user from the session or the bearer token. An MCP tool call gets whatever the agent sends. If the tool accepts a user_id parameter, the agent can supply someone else's. If the tool resolves the user from the request context, the agent still needs to be authenticated — but the agent is a service, not a session. The tool must resolve identity from a trusted context, not from the model's arguments.
The second problem is granularity. PATCH /users/{id} is a fine API endpoint. It is a terrible tool. It accepts arbitrary fields, which means the agent can change an email address, a password, and a role in a single call. A tool should do one thing. update_user_email is a tool. update_user is a liability.
The third problem is result shape. An API returns a resource. A tool should return a structured result that the model can reason about: success or failure, the relevant fields, and whether the failure is retryable. Dumping a full Eloquent model with 40 attributes into the context window is how you burn tokens and invite hallucination.
Laravel AI SDK's tool contract makes the granularity explicit. A tool implements description(), schema(JsonSchema $schema), and handle(Request $request). The description() tells the model when to use it. The schema tells it what inputs it takes. The handle() runs the query.
namespace App\Ai\Tools;
use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
class LookupOrderTool implements Tool
{
public function __construct(private readonly int $userId) {}
public function description(): Stringable|string
{
return 'Find one of the authenticated user\'s orders by ID. '
. 'Returns status, total, and line items.';
}
public function schema(JsonSchema $schema): array
{
return [
'order_id' => $schema->string()
->pattern('^ord_[A-Za-z0-9]{12,60}$')
->required(),
];
}
public function handle(Request $request): Stringable|string
{
$order = Order::query()
->where('user_id', $this->userId)
->where('id', $request['order_id'])
->select(['id', 'status', 'total', 'currency'])
->first();
if (! $order) {
return json_encode([
'status' => 'not_found',
'message' => 'No order with that ID belongs to you.',
]);
}
return $order->toJson();
}
}
The $userId comes from the constructor, not from the request. The agent cannot change it. The select() limits the columns. The pattern on order_id rejects malformed identifiers before the query runs. The not_found result is structured, so the model can distinguish "the order doesn't exist" from "the tool failed."
🔍 Why this matters: Every parameter the agent can supply is a parameter a prompt injection can attempt to manipulate. The safest parameters are the ones the model cannot see because they come from the application's context, not the conversation.
2. MCP Tool Contracts: One Job, Typed Result, Scoped Identity
MCP is the standard that lets an external AI client — Claude Desktop, Cursor, another agent — discover and call your tools. Laravel MCP 1.0, stable since September 2026, gives you a first-party server implementation with tools, resources, prompts, OAuth 2.1, and searchable tool catalogs.
The temptation is to expose every endpoint. The right move is to expose a small, hand-crafted surface. A tool catalog with 40 tools is a catalog the model cannot reason about well, and every tool definition consumes context before the user has asked a question. Laravel MCP 1.0's searchable catalogs mitigate the context cost, but they don't fix a poorly designed catalog.
The tool contract has three properties:
One job. A tool named issue_refund that takes order_id, amount_cents, and reason is a good tool. A tool named manage_billing that takes a free-text action is a bad tool, because the model has to guess the grammar of the action string, and the tool has to parse it.
Typed result. Return a JSON object with a status field and only the data the model needs. A result of { "status": "ok", "data": { ... } } is legible. A result that throws an exception is not, because the model sees a transport failure, not a business outcome.
Scoped identity. The tool resolves the user from the authenticated context, not from a parameter. In an MCP server, the authenticated context comes from the OAuth token or the Sanctum token, and the tool's handle() method should query with that identity built in.
namespace App\Mcp\Tools;
use App\Models\Order;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
class IssueRefundTool extends Tool
{
public string $name = 'issue_refund';
public string $description = 'Issue a refund for an order owned by the authenticated user. '
. 'Returns status=ok with refund_id, or status=error with a reason.';
public function schema(): array
{
return [
'type' => 'object',
'properties' => [
'order_id' => ['type' => 'string'],
'amount_cents' => ['type' => 'integer', 'minimum' => 1],
'reason' => ['type' => 'string', 'maxLength' => 200],
],
'required' => ['order_id', 'amount_cents', 'reason'],
];
}
public function handle(Request $request): Response
{
$order = Order::query()
->where('user_id', $request->user()->id)
->where('id', $request->input('order_id'))
->first();
if (! $order) {
return Response::json([
'status' => 'error',
'reason' => 'order_not_found',
'retryable' => false,
]);
}
$refund = $this->refunds->issue($order, $request->integer('amount_cents'));
return Response::json([
'status' => 'ok',
'refund_id' => $refund->id,
'amount_cents' => $refund->amount_cents,
]);
}
}
The $request->user() call is the identity binding. In a Laravel MCP server, the request carries the authenticated user from the OAuth or Sanctum middleware. The tool does not accept a user_id parameter. The retryable flag tells the orchestrator whether a failed call is worth retrying. A refund tool that returns retryable: false for a missing order is telling the agent to stop, not to try again with a different order ID.
⚠️ Gotcha: Do not expose a generic
query_databaseorrun_sqltool. That's not an MCP server; it's an injection surface with a schema. Every tool should have a narrow, intentional purpose, and the database access should be hidden behind that purpose.
3. The Multi-Agent Patterns That Actually Map to Business Workflows
A single agent with 20 tools is a routing problem disguised as an agent. It spends its first few reasoning steps deciding which tool to use, and it gets worse as the catalog grows. The fix is not a better prompt. It's splitting the work into agents with narrower responsibilities and explicit handoffs.
Laravel AI SDK ships five multi-agent patterns, based on Anthropic's published agent design research: prompt chaining, routing, parallelization, orchestrator-workers, and evaluator-optimizer. They are not academic. Each one maps to a recurring business workflow.
| Pattern | Business example | When to use | When not to use |
|---|---|---|---|
| Prompt chaining | Draft a reply → review tone → improve | Steps are sequential and each step improves the last | Steps are independent; chaining adds latency for no benefit |
| Routing | Billing question → billing agent; technical issue → support agent | Inputs fall into distinct categories with different tools | Categories overlap heavily; routing becomes a guess |
| Parallelization | Check fraud score and credit score simultaneously | Independent checks that can run at the same time | Checks depend on each other's output |
| Orchestrator-workers | A feature request → create files, modify tests, update docs | The subtasks are not known until the orchestrator reasons about the request | The workflow is fixed and enumerable |
| Evaluator-optimizer | Generate a summary → evaluate against criteria → rewrite | Quality can be scored against a rubric | There is no objective evaluation criterion |
Prompt chaining is the simplest and the most underused. A pipeline where each step receives the full payload and passes it forward, enriched, is easier to debug than a single agent that tries to do everything. Laravel's Pipeline is the natural fit:
$result = Pipeline::send(['ticket' => $ticket, 'draft' => '', 'review' => []])
->through([
fn ($payload, $next) => $next([
...$payload,
'draft' => $this->draftAgent($payload),
]),
fn ($payload, $next) => $next([
...$payload,
'review' => $this->reviewAgent($payload),
]),
fn ($payload, $next) => $next(
$this->improveAgent($payload)
),
])
->thenReturn();
Each step is a separate agent with its own instructions. The draft agent writes. The review agent scores personalization, tone, and call-to-action strength using structured output. The improve agent rewrites only if the scores fall short. The pipeline makes the sequence explicit, and each agent can be tested in isolation.
Orchestrator-workers is the pattern that most often goes wrong in production. The orchestrator receives a request, decides which workers to call, and aggregates the results. The failure mode is that the orchestrator delegates a task that requires context the worker doesn't have. If the worker runs in isolation — which sub-agents in Laravel AI SDK do — the orchestrator must pass a self-contained task description. A worker that receives "fix the bug" without the stack trace, the file path, and the failing test will guess.
💡 Practical note: Start with prompt chaining and routing. They cover most business workflows with the least complexity. Add orchestrator-workers only when the subtasks genuinely depend on the orchestrator's reasoning, not when you could enumerate the steps in advance.
4. Sub-Agents as Tools: Delegation Without Context Leakage
Laravel AI SDK's sub-agent feature turns an agent into a tool that another agent can call. The parent agent's tools() method returns a sub-agent class, and the parent delegates a specific task. The sub-agent carries its own instructions, its own tools, its own provider and model, and its own configuration.
namespace App\Ai\Agents;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;
class SupportAgent implements Agent, HasTools
{
use Promptable;
public function instructions(): string
{
return 'You are a customer support agent. Route billing questions '
. 'to the BillingAgent. Route technical issues to the TechAgent. '
. 'Handle general questions yourself.';
}
public function tools(): iterable
{
return [
new BillingAgent,
new TechAgent,
new LookupOrderTool($this->userId),
];
}
}
The isolation is the important detail. Each sub-agent invocation does not receive the parent's conversation history. That's not a limitation; it's a security property. The parent's conversation may contain data the sub-agent should not see. The parent must pass a self-contained task description, and the sub-agent returns a result that the parent uses.
The second important detail is model pinning. A sub-agent can run on a different provider or model from the parent. A cheap, fast model for routing, a stronger model for the refund decision, a code-specialized model for technical questions. The #[Provider] attribute makes this explicit:
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Enums\Lab;
#[Provider(Lab::Anthropic)]
class BillingAgent implements Agent, HasTools
{
// ...
}
The third detail is that sub-agents stream as well, unless they produce structured output. When the parent is streaming, the sub-agent's progress is emitted as preliminary tool result events, and the frontend can render it without custom code. That matters for the UX: a sub-agent that takes 15 seconds should not look like a frozen chat.
The mistake most teams make is building a hierarchy that is too deep. A parent that calls a sub-agent that calls another sub-agent is a debugging nightmare. Two levels — orchestrator and workers — is usually enough. If you need three, the middle layer is probably doing something that belongs in a tool or a pipeline step.
5. Human Approval: Binding the Gate to the Exact Call
The approval gate is the control that makes autonomy safe. Laravel AI SDK's HITL API, announced at Laracon US 2026 and shipped in v0.10.0, lets an agent pause before executing a tool and wait for a human to approve, reject, or edit the arguments.
The implementation is opt-in per tool. Implement the Approvable contract and use the InteractsWithApprovals trait, and the tool requires approval by default. Or use needsApproval() to decide based on the arguments — a refund under 20isauto−approved,arefundover20 requires a manager.
use Laravel\Ai\Approvals\Approval;
use Laravel\Ai\Concerns\InteractsWithApprovals;
use Laravel\Ai\Contracts\Approvable;
use Laravel\Ai\Contracts\Tool;
class IssueRefundTool implements Approvable, Tool
{
use InteractsWithApprovals;
protected function needsApproval(Request $request): Approval|bool
{
return $request['amount_cents'] <= 2000
? false
: Approval::required(
'Refunds over $20 need a manager.'
);
}
public function handle(Request $request): Stringable|string
{
$order = Order::findOrFail($request['order_id']);
$order->refund($request['amount_cents']);
return "Refunded {$request['amount_cents']} on order {$order->id}.";
}
}
When the model calls an approvable tool, the agent stops short of running it and returns the pending approvals on the response. Each pending approval carries the tool call ID, the tool name, the arguments, and the reason. To resume, you continue the conversation and pass a Decisions instance keyed by tool call ID.
$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
}
}
$response = (new SupportAgent)
->continue($conversationId, as: $user)
->prompt(Decisions::from([
'call_abc' => Decision::approve(),
'call_ghi' => Decision::reject('The invoice must be retained.'),
]));
The Decisions object is keyed by tool call ID, not by tool name. That's the control that prevents a subtle class of bug: if the approval is bound only to the tool name, the agent can change the arguments after approval and the human approves one action while a different one executes. The decision ID binds the approval to the exact call.
Every pending tool call must receive a decision. Unknown, missing, or previously resolved IDs throw an ApprovalMismatchException. That's deliberate: a missing decision is an ambiguous authorization, and ambiguous authorization should fail closed.
🚨 Production warning: Approval is not a substitute for scoping. A tool that can issue a refund should still be scoped to the authenticated user's orders. The approval gate controls whether the action runs; the tool contract controls what the action can touch. You need both.
6. Conversation State and the Streaming Contract
A multi-agent workflow that doesn't persist conversation state is a demo. A customer who comes back the next day should see the history, and a paused approval should survive a page reload. Laravel AI SDK 1.0 stores conversations in the database, including the steps that produced each tool call, so the agent can resume where it left off.
The streaming layer is where the UX lives. The SDK supports the Vercel Chat and AG-UI protocols natively, which means the same Laravel route can serve a React frontend using useChat, a CopilotKit client, and a CLI test harness without rewriting the agent logic.
use Illuminate\Http\Request;
use Laravel\Ai\Vercel\Vercel;
Route::post('/chat', function (Request $request) {
$chat = Vercel::chat($request);
return (new SupportAgent($request->user()->id))
->withMessages($chat->history())
->stream($chat)
->usingProtocol($chat->protocol());
});
The Vercel::chat($request) call reads the incoming message, updates the conversation history in the database, and processes any tool approvals the user submitted. The stream() call returns the response in the protocol the client expects. When a tool approval is pending, the response status is awaiting_approval, and the frontend should render the pending approvals and submit the user's choices to the same endpoint using the tool call ID as each decision's key.
The non-obvious part is that the stream carries structured events, not just text. A tool-call event, an approval-required event, and a token delta are different event types. The frontend should render them as different states, not collapse them into a generic loading spinner. A chat that shows "calling lookup_order" is more trustworthy than one that spins silently.
🧠 The important part: Streaming is not a performance optimization. It's an observability feature. The user should see what the agent is doing while it does it, especially when the agent is waiting for an approval or calling a slow tool.
7. MCP Security: Tool Poisoning, OAuth, and the Catalog Hash
An MCP server with access to a Laravel API is a high-value target. The 2026 threat model is well documented: tool poisoning — hiding instructions in tool descriptions or tool outputs — is the most prevalent client-side vulnerability, and indirect prompt injection through tool results is a close second. OWASP's MCP Top 10 lists tool poisoning, command injection, supply chain attacks, and excessive permissions as distinct risks.
The first control is authentication. The MCP specification mandates OAuth 2.1 for HTTP transport. Laravel MCP implements the discovery, metadata, and dynamic registration endpoints with a single call:
use Laravel\Mcp\Facades\Mcp;
Mcp::oauthRoutes();
That registers the authorization server metadata, protected resource metadata, and dynamic client registration. For external clients, configure Laravel Passport. For internal clients, a Sanctum token with a narrow scope is acceptable, provided the token is short-lived and scoped to the specific tools the client needs.
The second control is catalog integrity. The tools/list response is a supply chain artifact. If a third-party MCP server changes a tool definition after approval, the agent can be redirected without any change to your code. Snapshot the catalog, hash each tool definition, and refuse to load a changed tool until a human has reviewed the diff.
$hash = hash('sha256', json_encode([
'name' => $tool->name,
'description' => $tool->description,
'schema' => $tool->schema(),
], JSON_THROW_ON_ERROR));
if ($hash !== $approvedHashes[$tool->name]) {
throw new SecurityException("tool definition changed: {$tool->name}");
}
The third control is egress. An MCP server that can read data and reach the public internet can exfiltrate. Restrict the server's network egress to the specific upstream APIs it needs, and treat any attempt to reach an unapproved domain as a security event.
⚠️ Gotcha: Do not connect a production Laravel MCP server to a public MCP registry without an allowlist. The registry is a supply chain, and a significant fraction of public servers have excessive permissions or weak authentication. Connect to a small, reviewed set of servers, not to whatever the registry suggests.
8. The Orchestrator Agent: Routing, Budgets, and Kill Switches
The orchestrator is the agent that decides which workflow to run. It should not be the agent that executes every step. Its job is to classify the request, route it to the right specialist, and enforce the budget.
Classification is cheaper than conversation. Laravel AI SDK 1.0's classification capability uses Jev models to return typed answers in milliseconds. A support ticket can be classified as billing, technical, or sales before any conversational agent runs. That routing decision determines which agent gets the request, which tools it has access to, and which model it runs on.
use Laravel\Ai\Classification;
use Laravel\Ai\Classification\Choice;
$response = Classification::of($ticket->body)->questions([
'department' => new Choice('Which team should handle this?', [
'billing' => 'Payments, invoicing, refunds',
'technical' => 'Bugs, outages, integrations',
'sales' => 'Pricing, plans, upgrades',
]),
])->classify();
$agent = match ($response['department']->choice) {
'billing' => new BillingAgent($userId),
'technical' => new TechAgent($userId),
'sales' => new SalesAgent($userId),
};
The budget is the second responsibility. Every agent run should have a maximum number of turns, a maximum wall-clock time, and a maximum token spend. Without them, a failing tool and a stubborn model will loop until the bill arrives. The budget should be enforced by the orchestrator, not by the agent, because an agent that can extend its own budget will.
The kill switch is the third. A flag that the orchestrator checks before every run — stored in Redis or the database, not in the agent's context — lets an on-call engineer stop all automated runs in under a second. The agent cannot write to the flag. If the flag is set, the orchestrator returns an escalation instead of running the agent.
🔍 Why this matters: The orchestrator is the only component that sees the whole workflow. If the budget and the kill switch live anywhere else, they are advisory. If they live in the orchestrator, they are enforced.
9. Testing Agents Without Burning Tokens
An agent that calls a model on every test run is an agent with a slow, expensive, non-deterministic test suite. The fix is to separate the deterministic parts — tool logic, routing decisions, approval policy — from the non-deterministic parts, and test them independently.
Tool logic is plain Laravel code. Test it with feature tests against a test database, the same way you test a controller. The tool's handle() method should be callable without an agent, and the test should assert the structured result, including the error cases.
Routing logic is a classification call, and classification can be faked. Laravel's Sleep and Http fakes don't cover model calls, but the SDK's testing helpers do. The test should assert that a billing ticket routes to BillingAgent, not that the model produced a specific string.
Approval policy is a pure function. needsApproval() returns a boolean or an Approval instance. Test it directly: a 10refundisapproved,a50 refund requires approval, a refund with a missing order returns retryable: false. The policy is the part of the system that should never depend on the model.
The model itself should be tested with a small, curated evaluation set, not with unit tests. Ten representative prompts, scored by a rubric or a stronger model, run nightly or on demand. The evaluation set is the regression suite for prompt changes, and it should live in the repository alongside the code.
💡 Practical note: Mock the model for unit tests and use the real model for a small evaluation suite. Testing the prompt with assertions on exact strings is brittle; testing the tool and the policy with deterministic assertions is not.
The Approval Matrix: What to Automate and What to Escalate
The approval matrix is the decision framework for every tool in the system. It has two axes: reversibility and blast radius. A read is reversible (it changes nothing) and has a small blast radius (one record). A refund is irreversible (money leaves) and has a larger blast radius (a customer, a ledger, a reconciliation report). A deployment is irreversible and has the largest blast radius.
| Action class | Reversible? | Blast radius | Automation level | Approval |
|---|---|---|---|---|
| Read / lookup | Yes | Single record | Full | None |
| Draft / suggest | Yes | None until sent | Full | None |
| Internal write (non-production) | Yes | Single system | Full, with audit | Post-hoc notification |
| Production write | No | Multiple systems | Conditional | Synchronous, argument-bound |
| Refund / payment | No | Financial | No | Synchronous, threshold-based |
| Delete / rotate credentials | No | Security | No | Two-person |
| Deploy / infrastructure | No | Platform-wide | No | Human, with rollback plan |
The matrix is not a suggestion. It should be enforced by the tool contract and the orchestrator, not by the prompt. A tool that falls into the "No" automation column should implement Approvable and return Approval::required() unconditionally. A tool that falls into the "Full" column should not require approval, because approval fatigue is real: if every call needs a click, the human stops reading.
The system that gets this right is not the one with the most agents or the most tools. It's the one where every tool has a narrow contract, every agent has a clear responsibility, and every irreversible action has a gate that is bound to the exact call. Laravel gives you the primitives. MCP gives you the standard. The architecture is the set of boundaries you draw between them — and the discipline to keep them.