# Laravel Clutch
The complete documentation as one file: the guide, then the recipes.
Source: https://github.com/obaid/laravel-clutch
Short index for agents: https://obaid.github.io/laravel-clutch/llms.txt
----------------------------------------------------------------------------
[](https://packagist.org/packages/obaid/laravel-clutch)
[](https://github.com/obaid/laravel-clutch/actions/workflows/tests.yml)
[](https://packagist.org/packages/obaid/laravel-clutch)
[](LICENSE.md)
An agent harness for Laravel, built on the official [Laravel AI SDK](https://laravel.com/docs/ai).
A community package. Not affiliated with or endorsed by Laravel.
[Documentation](https://obaid.github.io/laravel-clutch/) · [Recipes](https://obaid.github.io/laravel-clutch/recipes/) · [Set it up with an agent](https://obaid.github.io/laravel-clutch/agents/)
Laravel AI gives you the agent. Clutch is the harness around it: sessions that outlive a request, runs you can queue and resume, an ordered event history you can replay, human approvals that survive a deploy, budgets, cancellation, and artifacts.
```php
$session = Clutch::agent(ResearchAgent::class)->for($user)->create();
$result = $session->prompt('Research our competitors and recommend a wedge.');
```
That reads like an ordinary call, and it is. What changes is what remains afterward: a session you can continue tomorrow, a run record with usage and cost, and every event in order.
## Why this exists
Prompting an agent with Laravel AI is already easy:
```php
$response = (new ResearchAgent)->prompt('Research our competitors and write a brief.');
```
That is the engine. It talks to the model, runs the tool loop, and hands you a response.
The trouble starts when the work does not fit in one request. Say the agent has to read fifty pages, call a few APIs, write a document, and get sign off before publishing. While it runs, any of this can happen:
* Someone closes the browser and comes back after lunch.
* A deploy restarts the queue worker mid run.
* A rate limit forces a retry ten minutes later.
* The publishing tool succeeds, then the worker dies before recording that it did.
* The agent pauses for approval and gets an answer the next morning, from a different process.
* The frontend drops the connection after event 42 and needs the ones it missed.
* The run passes its cost ceiling and has to stop without corrupting anything.
Laravel AI covers a lot of ground already: agents, conversations, tools, streaming, queues, approvals, sub agents, MCP, structured output, and provider abstraction. What it deliberately leaves alone is the runtime that owns the whole lifecycle of that work.
So every application ends up writing the same layer. Session and run tables. Queue orchestration and execution locks. Status transitions and audit history. Stream persistence and reconnect logic. Approval endpoints and continuation jobs. Checkpoints, crash recovery, tool idempotency, cancellation, budgets, usage accounting, artifact storage, tenant isolation, redaction, and the commands to inspect all of it.
Clutch is that layer, written once.
| Question | Answered by |
| --- | --- |
| Which model should answer this prompt? | Laravel AI |
| Which tools can the model call? | Laravel AI and your application |
| What conversation context does the model get? | Laravel AI |
| Who owns the run after the HTTP request ends? | Clutch |
| What happens when the browser or worker disconnects? | Clutch |
| Has this tool already performed its side effect? | Clutch |
| How does an approval resume in another process? | Clutch |
| What happened, in what order, and at what cost? | Clutch |
Laravel AI runs the agent. Clutch is the harness that keeps it running safely across requests, workers, and deploys. It is not another model abstraction or agent framework, and it is built on Laravel's own queues, events, broadcasting, storage, policies, database, container, and testing fakes.
## Where it sits
Clutch wraps Laravel AI instead of replacing it. Your agents, tools, and provider configuration stay exactly as they are.
```mermaid
flowchart LR
Controller["Your controller"]
Coordinator["RunCoordinator"]
Worker["Queue worker"]
Record[("sessions · runs · events approvals · checkpoints · ledger")]
Agent["Your agent instructions + tools"]
Conversation[("conversations")]
Provider["Model provider"]
Frontend["Frontend"]
Approver["Approver"]
Controller -->|queue a run| Coordinator
Coordinator -->|after commit| Worker
Worker -->|under a lease| Coordinator
Coordinator --> Record
Coordinator -->|run a turn| Agent
Agent --> Conversation
Agent --> Provider
Record -->|replay, then live| Frontend
Record -->|outlives the worker| Approver
Approver -->|resumes the run| Coordinator
classDef yours fill:#4f46e5,stroke:#3730a3,color:#ffffff
classDef harness fill:#0f766e,stroke:#115e59,color:#ffffff
classDef ai fill:#b45309,stroke:#92400e,color:#ffffff
class Controller,Frontend,Approver yours
class Coordinator,Worker,Record harness
class Agent,Conversation,Provider ai
```
Indigo is yours. Teal is the harness. Amber is Laravel AI.
Laravel AI talks to the model, runs the tool loop, and remembers the conversation. The harness decides when a turn starts, what gets recorded, who may approve what, when to stop, and how to pick the work back up. Your application starts the work, renders progress, and decides who approves.
Durability comes from a few unglamorous rules. The queue job is dispatched only after the run's state commits. Events are written before they are broadcast. An approval resolved in a completely separate process is what wakes the run back up.
### What one turn does
```
$session->prompt(…)
│
├─ createRun ─────────────── run.created ← one active run per session, held by a row lock
├─ acquire session lease ───────────────────── ← one worker, or the job exits
├─ restore checkpoint ──────────────────────── ← the conversation the last turn left
├─ transition to running ── run.started
│
│ ┌─ driver: Laravel AI stream ──────────────┐
│ │ step.started │
│ │ text.delta × n │
│ │ tool.call.requested ← ledger checks │ budgets checked at each
│ │ tool.call.completed idempotency │ boundary; cancellation
│ │ step.completed │ observed before a new step
│ └──────────────────────────────────────────┘
│
├─ store checkpoint ──────── checkpoint.created
├─ usage.updated
└─ commit terminal state ── run.completed ← state, result and event commit together;
the active-run slot clears in the same write
```
If the agent hits a tool that needs approval, the middle of that sequence ends at `run.awaiting_approval`, the worker exits, and everything resumes from the checkpoint once a decision arrives.
## DR;SA
Didn't read, sent to agent. Paste this into Claude Code, Codex, Cursor, or whatever you use, and let it install and wire up the package for you.
```text
Add Laravel Clutch to this application. It is a durable agent harness for
Laravel that wraps the official laravel/ai SDK rather than replacing it.
Read https://obaid.github.io/laravel-clutch/llms.txt first and follow the guide
it links to. Do not guess at the API surface.
Install it, publish and run the migrations for BOTH Clutch and Laravel AI, put
agent work on its own queue, and move the agent in this codebase that most
needs to outlive a request onto a Clutch session.
Two things fail silently if you skip them: the agent must use the
RemembersConversations trait and contract, and its tools must be returned
through Clutch::policy([...]) or none of the approval, idempotency or loop
guard machinery ever runs.
Show me the diff, the worker command, and one curl that starts a run.
```
[More prompts](https://obaid.github.io/laravel-clutch/agents/) for moving an existing Laravel AI agent across, adding approval to a single tool, and building a progress UI, plus the mistakes worth checking for afterwards.
Agents can read the whole thing at [llms.txt](https://obaid.github.io/laravel-clutch/llms.txt), or [llms-full.txt](https://obaid.github.io/laravel-clutch/llms-full.txt) for the guide and every recipe as one file.
## Requirements
* PHP 8.3 or newer
* Laravel 12 or 13
* `laravel/ai` 0.11.x. Laravel AI is still pre-1.0, so the constraint pins the
minor deliberately: a 0.12 release may move the interfaces Clutch builds on,
and that should be verified before it is allowed.
* PostgreSQL for production. SQLite is fine for tests.
* Redis for queues, leases, and broadcasting
## Installation
```bash
composer require obaid/laravel-clutch
# The Clutch tables.
php artisan vendor:publish --provider="Clutch\Laravel\ClutchServiceProvider"
# Laravel AI's conversation tables, if you have not published them already.
# Session context lives there, so this step is not optional.
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate
```
Configure at least one Laravel AI provider the usual way.
Give agents a queue of their own while you are here. A run that takes four minutes should not sit in front of your password reset emails.
```env
CLUTCH_QUEUE_CONNECTION=redis
CLUTCH_QUEUE=agents
```
```bash
php artisan queue:work redis --queue=agents
```
## The mental model
Five nouns, and that is the whole surface.
| Concept | Meaning |
| --- | --- |
| Agent | A Laravel AI agent class: instructions and tools |
| Session | Durable identity, context, configuration, optional workspace |
| Run | One execution attempt inside a session |
| Event | An append only fact emitted during a run |
| Artifact | A durable output: a document, report, image, export |
A session holds many runs, one after another, and only one of them can be active at a time.
## Getting started
### Write an agent
```bash
php artisan make:clutch-agent ResearchAgent
```
You get an ordinary Laravel AI agent with one extra thing wired in:
```php
namespace App\Ai\Agents;
use Clutch\Laravel\Facades\Clutch;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\RemembersConversations as RemembersConversationsContract;
use Laravel\Ai\Promptable;
use Stringable;
class ResearchAgent implements Agent, HasTools, RemembersConversationsContract
{
use Promptable;
// Laravel AI persists this agent's conversation, which is what lets a
// harness session continue across requests, workers and deployments.
use RemembersConversations;
public function instructions(): Stringable|string
{
return 'You research competitors and write positioning briefs.';
}
public function tools(): iterable
{
return Clutch::policy([new SearchWeb, new FetchPage]);
}
}
```
The `RemembersConversations` trait is doing real work here. Laravel AI only persists a conversation for agents that use it, and both durable sessions and cross process approvals depend on that conversation existing. Create a session with an agent that lacks the trait and the harness refuses, with an error that shows you the exact code to add. If the agent really is single turn, say so on purpose with `->configure('stateless', true)`.
### Create a session and prompt it
```php
use Clutch\Laravel\Facades\Clutch;
use App\Ai\Agents\ResearchAgent;
$session = Clutch::agent(ResearchAgent::class)
->for($request->user())
->name('Competitor research')
->create();
$result = $session->prompt(
'Research our three closest competitors and recommend a positioning wedge.'
);
return [
'session_id' => $session->id,
'run_id' => $result->run->id,
'text' => $result->text,
'artifacts' => $result->artifacts,
'usage' => $result->usage,
];
```
The session, run, events, tool activity, approvals, artifacts, usage, and terminal result are all persisted.
### Continue it later
```php
$session = Clutch::session($sessionId)->authorizeFor($request->user());
$result = $session->prompt('Turn the recommendation into a one-page strategy memo.');
```
The driver restores the right conversation and runtime state, so you never replay chat messages by hand.
## Background runs
Queue anything that will take a while:
```php
$run = $session->queue('Analyze every page on our website and produce a content gap report.');
return response()->accepted([
'session_id' => $session->id,
'run_id' => $run->id,
'status' => $run->status,
]);
```
Queue behavior is configurable globally in `config/clutch.php`, or per session:
```php
$session = Clutch::agent(ResearchAgent::class)
->for($user)
->onConnection('redis')
->onQueue('agents')
->timeout(seconds: 900)
->create();
```
## Streaming and reconnecting
Stream a run straight from a controller:
```php
Route::post('/research', function (Request $request) {
$session = Clutch::session($request->session_id)->authorizeFor($request->user());
return $session
->stream('Create the report and explain your progress.')
->usingVercelDataProtocol();
});
```
That plugs directly into the Vercel AI SDK's `useChat`. Drop `usingVercelDataProtocol()` and you get the harness event envelope instead.
Queued runs are consumed through the event stream:
```http
GET /api/clutch/runs/{run}/events?after=42
Accept: text/event-stream
```
Every event carries a sequence number that only goes up. A browser that disconnects after event 42 reconnects with `after=42`, and the server replays what it stored before switching to live events.
Frames carry `id:` and `data:` only. The SSE event is deliberately not named
after the type, because that would stop `EventSource.onmessage` firing and force
every client to register a listener per type it might ever see. The type is on
the payload, so the obvious client is the correct one:
```js
const source = new EventSource(`/api/clutch/runs/${runId}/events?after=${cursor}`)
source.onmessage = (message) => {
if (message.data === '[DONE]') return source.close()
const event = JSON.parse(message.data) // event.type, event.sequence, event.payload
}
```
Network delivery is at least once, so clients should deduplicate by `(run_id, sequence)`. Storage is exactly once and ordered.
The stored history is the same whether a run was streamed directly or executed on a queue, so a reconnecting client sees the same thing either way.
## Human approval
Approvable Laravel AI tools become durable harness approvals on their own.
```php
use Laravel\Ai\Concerns\InteractsWithApprovals;
use Laravel\Ai\Contracts\Approvable;
class PublishArticle implements Tool, Approvable
{
use InteractsWithApprovals;
// ...
}
```
When the agent reaches that tool the run pauses, a checkpoint is written, and the worker exits normally. Nothing sits open holding a connection. Hours later, in a different process:
```php
$run = Clutch::run($runId)->authorizeFor($user);
$run->approve(
approvalId: $approvalId,
reason: 'The content has been reviewed and may be published.',
);
```
Rejecting works the same way:
```php
$run->reject(
approvalId: $approvalId,
reason: 'Do not publish this draft. Remove the unsupported claim first.',
);
```
A rejection comes back to the agent as a tool result it can respond to, rather than killing the run outright.
Decisions are idempotent. Repeating one returns the existing result. Trying to reverse a resolved decision raises `ApprovalAlreadyResolved`.
Endpoints for all of this ship with the package:
```http
GET /api/clutch/runs/{run}/approvals
POST /api/clutch/runs/{run}/approvals/{approval}/approve
POST /api/clutch/runs/{run}/approvals/{approval}/reject
```
Notifications are yours to wire up:
```php
Event::listen(ApprovalRequested::class, function (ApprovalRequested $event) {
$event->approval->session->participant->notify(
new AgentNeedsYou($event->approval)
);
});
```
### Permission modes
```php
use Clutch\Laravel\Enums\PermissionMode;
$session = Clutch::agent(PublishingAgent::class)
->for($user)
->permissions(PermissionMode::ApproveSensitive)
->create();
```
| Mode | Behavior |
| --- | --- |
| `DenyByDefault` | Only explicitly allowed tools may run |
| `ApproveSensitive` | Safe tools run, sensitive ones need approval |
| `ApproveAll` | Every state changing tool needs approval |
| `AllowAll` | No harness approval, for trusted environments |
Classify tools in configuration:
```php
'permissions' => [
'tools' => [
'search_web' => 'read_only',
'draft_email' => 'reversible',
'send_email' => 'irreversible',
],
],
```
Or let a tool speak for itself:
```php
class PublishArticle implements Tool, SensitiveTool
{
public function sensitivity(): ToolSensitivity
{
return ToolSensitivity::Irreversible;
}
}
```
A tool you have not classified counts as sensitive.
For the mode to apply, pass your tool list through `Clutch::policy()`:
```php
public function tools(): iterable
{
return Clutch::policy([
new SearchWeb,
new DraftEmail,
new PublishArticle,
]);
}
```
Laravel AI asks the agent for its tools on every turn and that list is usually rebuilt each time, so the harness cannot rewrite it afterward. The agent hands it over instead. `make:clutch-agent` writes this for you.
With that in place, a tool the mode denies is withheld from the agent completely. The model is never told it exists, since refusing after the fact would still have exposed the capability. A tool that needs sign off is marked approvable, which is what pauses the run and creates the durable approval. Outside a harness run `Clutch::policy()` does nothing at all, so the same agent still behaves normally when you prompt it directly through Laravel AI.
A tool that asks for approval on its own, through Laravel AI's `requireApproval()`, is making its author's call rather than the harness's. `AllowAll` relaxes harness policy and leaves that alone.
A session can also name the tools it may use at all, which is coarser than the
permission mode and applies before it:
```php
Clutch::agent(SupportAgent::class)->onlyTools(['search_orders', 'draft_reply'])->create();
Clutch::agent(SupportAgent::class)->withoutTools(['issue_refund'])->create();
```
An allow list is absolute: anything absent is withheld whatever the mode would
have permitted. Tools that survive the list are still subject to the mode, so an
irreversible tool on an allow list still asks for approval.
Laravel authorization policies apply regardless of mode. Application rules can layer on as well, and the most restrictive answer wins:
```php
app(PolicyEngine::class)->extend(function (ToolInvocation $invocation, ToolSensitivity $sensitivity) {
return $invocation->tenantId && Team::find($invocation->tenantId)?->on_trial
? PolicyDecision::requireApproval('Trial teams review every write.')
: null; // Defer to the other rules.
});
```
## Skills
Instructions describe how an agent always behaves. A skill describes how to do
one particular job, and only takes up context while that job is at hand.
```php
use Clutch\Laravel\Skills\Skill;
use Clutch\Laravel\Skills\SkillRegistry;
app(SkillRegistry::class)->add(new Skill(
name: 'refund-policy',
description: 'How we decide and process a refund.',
content: <<<'TEXT'
Refunds under $50 are automatic. Above that, check the account age first...
TEXT,
));
```
Or keep them as files, one directory per skill with a `SKILL.md` inside:
```php
// config/clutch.php
'skills' => ['path' => resource_path('skills')],
```
The model sees every skill's name and description, and pulls in the body of the
one it needs. Ten procedures pasted into instructions cost tokens on every turn
of every session; ten skills cost a line each until one is used.
Give a session a subset when it should not see everything:
```php
Clutch::agent(SupportAgent::class)->withSkills(['refund-policy'])->create();
```
## Workflows
An agent decides what to do next. Sometimes you already know, and the only
open question is the judgement in the middle.
A workflow is a finite job where you write the control flow in ordinary PHP
and call an agent at the points that actually need one. What the harness adds
is that the job survives the process running it.
```bash
php artisan make:clutch-workflow OnboardCustomer
```
```php
use Clutch\Laravel\Workflows\Workflow;
class OnboardCustomer extends Workflow
{
protected static ?string $agent = ResearchAgent::class;
public function handle(array $payload): mixed
{
$research = $this->step('research', fn () => $this->prompt(
"Research {$payload['domain']} and summarise how they sell."
)->text);
$this->emit('researched', ['chars' => strlen($research)]);
$decision = $this->pause('sign-off', ['research' => $research]);
if (! $decision['approved']) {
return ['skipped' => $decision['reason']];
}
return $this->step('provision', fn () => $this->provision($payload, $research));
}
}
```
```php
$run = OnboardCustomer::dispatch(['domain' => 'acme.com']);
// Later, from another request entirely.
Workflow::resume($run->id, ['approved' => true]);
```
### Steps run once, ever
`handle()` runs from the top again on every resume. That is deliberate, and it
is the only rule you need to hold in your head: **anything you cannot afford to
repeat goes inside a step.**
The first time through, a step runs its closure and stores the result. Every
later re-entry returns the stored value without calling the closure at all.
Results are persisted before the next step begins, so a worker that dies
between two steps never costs you the one that just finished.
```php
$invoice = $this->step('charge', fn () => $this->stripe->charge($customer));
```
Resume that workflow a dozen times and the card is charged once.
The same applies to a failure. A workflow that fails in its fourth step and is
retried with `clutch:retry` re-enters with the first three already done.
Step results have to survive a JSON round trip, which rules out returning a
model. Return the id and look it up.
### Steps that do not depend on each other
```php
$data = $this->steps([
'account' => fn () => $this->billing->account($id),
'usage' => fn () => $this->metrics->usage($id),
]);
```
These run together through Laravel's own concurrency driver, and each is
persisted as it lands. If one fails, the resume re-runs only that one. Set
`clutch.workflows.concurrent_steps` to `false` to run them in sequence, which
is sometimes what you want in a test.
### Pausing for a human
`pause()` stops the workflow, writes a checkpoint, and lets the worker exit.
Nothing holds a connection, a process, or an open transaction while the answer
is outstanding.
```php
$decision = $this->pause('sign-off', ['draft' => $draft], 'Check the pricing claim.');
```
The array you pass is what the decision maker sees. Whatever `resume()` is
given comes back as the return value, so a rejection is an answer rather than
a failure: the workflow decides what to do with it.
```php
Workflow::resume($run->id, ['approved' => false, 'reason' => 'Fix the pricing claim.']);
```
Under the hood this is the same approval machinery agents use, so the pending
records, the endpoints, and the events are the ones you already have.
### Agents inside a workflow
`prompt()` calls the workflow's declared agent, or any other:
```php
$draft = $this->prompt("Write it up:\n{$research}", WriterAgent::class)->text;
```
Each agent gets a session of its own, created once per workflow run and then
reused, so a second prompt continues the same conversation. Those sessions
record the workflow that caused them, which is what lets you trace an agent's
run back to the job it belonged to.
### When the agent itself needs approval
An agent prompted from a workflow can hit an approvable tool of its own. The
workflow does not carry on: the agent has not done the work yet, so the pause
travels outward and the workflow's run parks with the agent's real tool call
shown on it.
The step it happened inside is deliberately not recorded, which is the part
that matters. Recording it would treat a prompt the agent never finished as a
finished one, and no later resume would go back for it.
```php
Workflow::resume($run->id, ['approved' => true]);
```
That decision reaches the agent, its run continues from where it stopped, and
the workflow carries on with the real result. A rejection travels the same way
and comes back to the agent as a tool result, so the run finishes either way.
### Files and outputs
Stage inputs before any work begins, and declare what you expect to come out:
```php
public function produces(): array
{
return ['reports/*.md'];
}
public function handle(array $payload): mixed
{
$this->stage(['brief.txt' => $payload['brief']]);
// ... work happens, writing into the workspace ...
$this->workspace()->put('reports/findings.md', $findings);
}
```
Anything matching `produces()` is collected into artifacts when `handle()`
returns, with integrity hashes and authorized downloads like any other
artifact. `artifact()` records one directly, and `restoreArtifacts()` pulls
earlier ones back into the workspace for a later stage to read.
The workspace is an ordinary Laravel disk scoped to the run, so staging works
with no sandbox provisioned at all. A provider that gives real isolation mounts
that same path rather than inventing its own.
### What you get for free
A workflow is a session and a run like any other, which is the whole reason to
build it this way. Budgets, cancellation, leases, the event log, `clutch:events`,
the SSE stream, retries, and the reaper that recovers a run from a worker that
vanished all apply without workflows reimplementing any of it.
```bash
php artisan clutch:events {run} # steps, pauses, and agent activity in order
php artisan clutch:retry {run} # re-enters, skipping the steps that finished
```
Cancellation is checked between steps, never inside one, because a step that
has begun is not safe to abandon halfway.
## Long runs that outlive a worker
A run that takes twenty minutes will meet a queue worker timeout. Rather than
being killed part-way through a step, a run can hand the turn back at a safe
boundary and be re-queued to continue:
```php
Clutch::agent(ResearchAgent::class)
->for($user)
->sliceAfterSeconds(240) // below your worker's timeout
->create();
```
Each slice checkpoints, records a `run.suspended` event, and queues the
continuation. Usage accumulates across slices, so a run cut into ten pieces
still meets its budget as one run.
Slicing needs a driver that can park a turn and pick it up again. The bundled
`laravel-ai` driver cannot: Laravel AI runs a turn to completion, and abandoning
it part-way discards the work rather than parking it. Asking that driver to slice
fails with `CapabilityUnsupported` before the run starts, which is the same
promise the package makes everywhere else.
## Keeping a run on the rails
Budgets stop a run that is expensive. They do nothing about one that is cheap
and useless, like an agent calling the same tool with the same arguments forty
times. Loop guards notice that shape:
```php
// config/clutch.php
'guards' => [
'remind_after_repeats' => 3, // tell the model it is going in circles
'block_after_repeats' => 8, // refuse, and say why
'tool_timeout_seconds' => 60, // bound one call, not just the whole run
'tool_timeouts' => ['scrape_page' => 120],
],
```
A reminder still lets the call run. A block returns the refusal to the model as
the tool result, which is what tells the agent to try something else rather than
leaving it silently starved.
Tool deadlines cover what a run-level duration budget cannot: a budget only
notices an overrun once the tool returns, which is no help when the tool is the
thing that hung.
## Oversized tool output
A tool that returns a 400KB page dump poisons every later step. The model pays
for it on each turn, and the context fills with text nobody reads.
```php
// config/clutch.php
'spill' => [
'enabled' => true,
'threshold_bytes' => 8192,
'preview_bytes' => 1024,
],
```
Past the threshold, the full output is written to an artifact and the model gets
a bounded preview plus the artifact id, with the truncation stated plainly so it
does not answer from the fragment as though it were the whole thing. The full
text stays downloadable through the ordinary artifact route.
## Compaction
A long session accumulates conversation until every turn pays for the whole
history. Compaction summarizes the middle of it, keeping the earliest turns
(which hold the task) and the most recent (which hold the state).
```php
// config/clutch.php
'compaction' => [
'enabled' => true,
'trigger_at_fraction' => 0.7, // of the token budget
'keep_first' => 2,
'keep_recent' => 8,
],
```
The summary comes from Laravel AI's own `SummarizeAgent`, which is marked to use
the cheapest model available, so compaction does not cost more than the context
it saves. It is off by default: quietly rewriting an application's conversation
is not something to do without being asked.
## Budgets
Constrain a session or a single run:
```php
use Clutch\Laravel\ValueObjects\RunBudget;
$session = Clutch::agent(ResearchAgent::class)
->for($user)
->budget(new RunBudget(
maxSteps: 40,
maxToolCalls: 100,
maxTokens: 250_000,
maxCostUsd: 8.00,
maxDurationSeconds: 900,
))
->create();
```
Budgets layer: configuration defaults first, then the session, then the run. At each level the more restrictive limit wins, so a session can tighten a ceiling but never raise it.
A run that hits a hard limit moves to `budget_exceeded`, emits a terminal event naming the limit that ran out, and keeps its last safe checkpoint.
Usage carries across attempts, so a retry loop cannot spend the same budget three times. Reset it when you actually mean to:
```php
$run->retry(resetBudget: true);
```
`maxCostUsd` needs prices to work against:
```php
// config/clutch.php
'pricing' => [
'anthropic:claude-sonnet-4-5' => ['input' => 3.00, 'output' => 15.00],
'openai:gpt-5' => ['input' => 1.25, 'output' => 10.00],
],
```
An unpriced model contributes `0.00` rather than a guess. That shows up in the usage record, which beats a plausible number that happens to be wrong.
## Cancellation
```php
$run = Clutch::run($runId)->authorizeFor($user);
$run->cancel(reason: 'The request is no longer needed.');
```
Cancellation is cooperative. The request is recorded immediately, the active driver is signalled, and no new model step or tool starts after that. A tool already running may finish, unless it supports interruption. The package does not pretend otherwise.
Long running tools can cooperate:
```php
public function handle(Request $request): string
{
foreach ($this->pages() as $page) {
if (RunContext::current()?->isCancelled()) {
break;
}
$this->analyze($page);
}
}
```
## Artifacts
Agents and tools attach durable outputs to a run:
```php
use Clutch\Laravel\Artifacts\Artifact;
use Clutch\Laravel\Runtime\RunContext;
$artifact = Artifact::fromStorage(disk: 's3', path: 'reports/content-gap-2026-08-24.pdf')
->name('Content gap report')
->mimeType('application/pdf')
->metadata(['pages' => 18]);
RunContext::current()->artifacts()->add($artifact);
```
Or write the bytes as part of attaching them:
```php
Artifact::fromContents($markdown, 'reports/gap.md')->name('Content gap report');
```
Read them back from a result or a run:
```php
$artifacts = $result->artifacts;
$artifacts = Clutch::run($runId)->artifacts;
```
Contents stay on the filesystem disk you configured. The database holds metadata, ownership, a SHA-256 for integrity, and a storage reference. The bytes never land in an event payload.
## Idempotent tools
Any tool with an external side effect should implement `IdempotentTool`:
```php
use Clutch\Laravel\Contracts\IdempotentTool;
use Clutch\Laravel\Data\ToolInvocation;
use Laravel\Ai\Contracts\Tool;
final class PublishArticle implements Tool, IdempotentTool
{
public function idempotencyKey(ToolInvocation $invocation): string
{
// Key the side effect, not the call. Two retries of "publish article 42"
// must produce the same key even though the tool-call IDs differ.
return 'publish-article:'.$invocation->arguments['article_id'];
}
// Standard Laravel AI tool methods...
}
```
The key and a pending row are committed before the tool runs, so a worker that dies halfway through still leaves evidence behind. A repeat invocation returns the stored result instead of firing the side effect again.
This only applies to tools passed through `Clutch::policy()`. Laravel AI runs
tools inside its own loop, so the wrapper that `policy()` adds is the only place
the ledger, the loop guards, the deadlines and the spill policy can sit. An
agent that returns its tools directly gets none of them, which is why
`make:clutch-agent` generates the call.
A tool with no idempotency contract still gets recorded for audit, but the harness makes no duplicate suppression claim for it. Only you know what counts as the same action.
## Events
Every event uses one envelope:
```json
{
"id": "evt_01j...",
"session_id": "ses_01j...",
"run_id": "run_01j...",
"sequence": 42,
"type": "tool.call.requested",
"occurred_at": "2026-08-24T21:15:12.381Z",
"payload": {
"tool_call_id": "call_01j...",
"tool": "dataforseo.keyword_ideas",
"arguments": { "keyword": "AI receptionist" }
}
}
```
The core types:
`run.created` · `run.queued` · `run.started` · `text.delta` · `reasoning.delta` · `step.started` · `step.completed` · `tool.call.requested` · `tool.call.completed` · `tool.call.failed` · `approval.requested` · `approval.resolved` · `artifact.created` · `usage.updated` · `checkpoint.created` · `run.awaiting_approval` · `run.completed` · `run.failed` · `run.cancelled` · `run.budget_exceeded`
Listen through Laravel events, with no dependency on the transport:
```php
use Clutch\Laravel\Events\ClutchEventRecorded;
Event::listen(ClutchEventRecorded::class, function (ClutchEventRecorded $event) {
// Audit, analytics, metering, notifications, or your own broadcasting.
});
```
### Redaction
Redaction runs before persistence, not before display, so a configured key never reaches the database:
```php
'events' => [
'redact' => ['authorization', 'api_key', 'token', 'password', 'secret'],
],
```
Tool arguments often carry business sensitive values that are not secrets. Register a serializer that keeps only the fields you approve:
```php
class EmailEventSerializer implements EventSerializer
{
public function serialize(array $payload): array
{
return [
'tool' => $payload['tool'],
'arguments' => ['recipient_count' => count($payload['arguments']['to'])],
];
}
}
```
```php
'events' => [
'serializers' => ['send_email' => EmailEventSerializer::class],
],
```
## Structured output
Structured Laravel AI agents work as they normally do:
```php
$result = $session->prompt('Score this draft against our content rubric.');
$score = $result->structured['score'];
```
The validated value is stored on the terminal run record and emitted with `run.completed`.
## Drivers
The default `laravel-ai` driver runs ordinary Laravel AI agents inside your own workers:
```php
$session = Clutch::agent(ResearchAgent::class)->driver('laravel-ai')->create();
```
Drivers put different runtimes behind one set of session, run, event, approval, checkpoint, and artifact contracts.
```php
// A future coding-agent driver.
$session = Clutch::runtime('codex')
->for($user)
->workspace($repository)
->sandbox('e2b')
->create();
```
Every driver declares what it can do, and the harness checks before it acts. Asking for something a driver does not support fails with `CapabilityUnsupported` before any work begins, so a safety or durability feature never quietly degrades into something weaker.
Writing a driver? There is a shared contract suite you can point at it:
```php
use Clutch\Laravel\Testing\DriverContractTests;
it('passes the harness driver contract', function () {
$result = DriverContractTests::for(new MyDriver)->run();
expect($result['passed'])->toContain('round-trips a checkpoint');
});
```
Capabilities you do not declare are skipped rather than failed, so a deliberately limited driver still passes. What it cannot do is claim a capability it lacks.
## Workspaces and sandboxes
Application agents do not need a sandbox. Laravel AI tools run in the Laravel host under your normal authorization.
Runtimes that want a filesystem, shell, browser, or isolated process can ask for a workspace and a sandbox provider. Those providers are optional extensions, and the default driver uses `NullSandboxProvider`.
Secrets are resolved at runtime, scoped to the session, and never written into event payloads, checkpoints, artifacts, or the visible workspace. The checkpoint store enforces this: a driver that tries to checkpoint a configured secret key gets an exception rather than a quietly stripped value.
## Inspecting runs
```bash
php artisan clutch:sessions # list sessions
php artisan clutch:run {run-id} # inspect one run
php artisan clutch:events {run-id} # replay its event history
php artisan clutch:retry {run-id} # queue a fresh attempt
php artisan clutch:cancel {run-id} # request cooperative cancellation
php artisan clutch:reap # recover runs whose worker vanished
php artisan clutch:prune # apply retention windows
```
`clutch:events` prints a readable timeline:
```
1 21:15:09.204 run.created Research our three closest competitors
2 21:15:09.211 run.started
3 21:15:09.980 step.started
4 21:15:10.442 tool.call.requested search_web {"query":"competitor pricing"}
5 21:15:12.118 tool.call.completed search_web → "12 results"
6 21:15:12.940 text.delta Their weakest flank is onboarding...
7 21:15:13.002 usage.updated tokens=1841
8 21:15:13.010 run.completed Their weakest flank is onboarding.
```
Sensitive fields are already gone, because they never reached storage.
## Testing
Fake the harness. No provider, no queue worker, no network, but the real coordinator, event store, approvals, and artifacts:
```php
use Clutch\Laravel\Facades\Clutch;
use Clutch\Laravel\Runtime\ClutchResult;
Clutch::fake([
'Draft a brief' => ClutchResult::text('The proposed brief...'),
]);
$session = Clutch::agent(ResearchAgent::class)->for($user)->create();
$session->queue('Draft a brief');
Clutch::assertSessionCreated(ResearchAgent::class);
Clutch::assertRunQueued('Draft a brief');
Clutch::assertRunCompleted();
Clutch::assertNothingAwaitingApproval();
```
Test a paused run:
```php
Clutch::fake([
ClutchResult::awaitingApproval(
tool: 'publish_article',
arguments: ['article_id' => 123],
),
]);
$run = $session->queue('Publish the approved article.');
Clutch::assertApprovalRequested('publish_article');
```
Script richer ones:
```php
Clutch::fake([
ClutchResult::text('Report ready.')
->withToolCall('search_web', ['q' => 'pricing'], '12 results')
->withArtifact(Artifact::fromContents($pdf, 'report.pdf')->name('Report')),
ClutchResult::structured(['score' => 87]),
ClutchResult::failure('The provider is down.'),
]);
```
The assertions available: `assertSessionCreated`, `assertNoSessionCreated`, `assertRunQueued`, `assertRunCompleted`, `assertRunFailed`, `assertRunCancelled`, `assertRunExceededBudget`, `assertRunAwaitingApproval`, `assertApprovalRequested`, `assertApproved`, `assertRejected`, `assertNothingAwaitingApproval`, `assertArtifactCreated`, `assertEventRecorded`, `assertPromptedTimes`, `assertNothingPrompted`.
Failure messages say what actually happened:
```
Expected a harness run to have reached [completed] for a prompt containing [Draft a brief].
Runs recorded: awaiting_approval ("Draft a brief")
```
## Configuration
The published `config/clutch.php` is commented throughout. The keys you will touch first:
```php
return [
'default_driver' => env('CLUTCH_DRIVER', 'laravel-ai'),
'queue' => [
'connection' => env('CLUTCH_QUEUE_CONNECTION'),
'queue' => env('CLUTCH_QUEUE', 'agents'),
'timeout' => 900,
],
'permissions' => [
'default' => PermissionMode::ApproveSensitive->value,
'tools' => [
'search_web' => 'read_only',
'draft_email' => 'reversible',
'send_email' => 'irreversible',
],
],
'events' => [
'broadcast' => true,
'persist_deltas' => true,
'redact' => ['authorization', 'api_key', 'token', 'password', 'secret'],
'serializers' => [],
],
'budgets' => [
'max_steps' => 50,
'max_tool_calls' => 100,
'max_tokens' => 250_000,
'max_cost_usd' => null,
'max_duration_seconds' => 900,
],
'retention' => [
'events' => 90,
'checkpoints' => 30,
'artifacts' => 365,
],
'routes' => [
'enabled' => true,
'prefix' => 'api/clutch',
'middleware' => ['api', 'auth'],
],
];
```
Set `routes.enabled` to `false` if you would rather own the HTTP surface yourself.
## What it guarantees
1. One active run per session.
2. Ordered, append only run events.
3. Durable terminal run state.
4. Replayable events after a client reconnects.
5. Idempotent approval decisions.
6. No new step after cancellation is observed.
7. Unsupported driver capabilities fail loudly.
8. Secrets stay out of persisted payloads.
9. Retrying an idempotent tool does not repeat its side effect.
10. A run is never reported complete before its terminal event and result are committed.
11. A suspended turn resumes from its checkpoint without repeating finished work.
12. A blocked tool call never reaches the tool.
Each of these has a named test, so a regression shows up as a failure with a name on it rather than as strange behavior in production.
### What it does not
The `laravel-ai` driver checkpoints at safe model and tool boundaries. It does not promise byte perfect continuation from the middle of an active provider request. If a worker dies during that request the current step may run again, which is exactly why side effecting tools need an idempotency contract.
It also will not claim to interrupt a tool that cannot be interrupted, and it cannot make a non idempotent external action safe to retry.
## A demo you can try to break
[obaid/laravel-clutch-demo](https://github.com/obaid/laravel-clutch-demo) is a mock CRM with an assistant panel down the right-hand side. It reads deals, logs notes, moves things between stages, and stops to ask before it emails a prospect or discounts a deal.

Every screen is something that would go wrong in a naive build. Close the tab and the work carries on. Reload mid-run and the stream resumes from your cursor, approval card included, because it rebuilds from the event log rather than from browser memory. Ask for the same discount three times and the tool body still runs once. Kill the worker and the reaper recovers the run from its checkpoint.
Five of its seven tools never ask permission, which is the part worth copying. Approval is for the two things you cannot take back.
## Created by teams from
- [ONE](https://osone.ai)
- [Provision AI](https://provision.ai)
- [CrewX](https://crewx.ai)
## Documentation
Full docs at [obaid.github.io/laravel-clutch](https://obaid.github.io/laravel-clutch/), including [recipes](https://obaid.github.io/laravel-clutch/recipes/) for approval inboxes, live progress UIs, multi tenant scoping, and spend caps, and [setup prompts](https://obaid.github.io/laravel-clutch/agents/) to hand to a coding agent.
For machines: [llms.txt](https://obaid.github.io/laravel-clutch/llms.txt) is a short index, [llms-full.txt](https://obaid.github.io/laravel-clutch/llms-full.txt) is everything in one file.
## License
MIT
----------------------------------------------------------------------------
# Recipes
Complete examples you can lift into an application. Each one builds on ordinary [Laravel AI](https://laravel.com/docs/ai) agents and tools.
- [1. A research agent with an approval inbox](#1-a-research-agent-with-an-approval-inbox)
- [2. A live progress UI that survives a refresh](#2-a-live-progress-ui-that-survives-a-refresh)
- [3. Multi-tenant agents](#3-multi-tenant-agents)
- [4. Capping spend per customer plan](#4-capping-spend-per-customer-plan)
- [5. A tool that must never double-charge](#5-a-tool-that-must-never-double-charge)
- [6. Nightly batch runs](#6-nightly-batch-runs)
- [7. A structured grading agent](#7-a-structured-grading-agent)
- [8. Testing the whole thing](#8-testing-the-whole-thing)
---
## 1. A research agent with an approval inbox
An agent researches competitors and drafts a blog post. Publishing is irreversible, so a human signs off first, possibly the next morning from a different device.
### The agent
```php
namespace App\Ai\Agents;
use App\Ai\Tools\PublishArticle;
use App\Ai\Tools\SearchWeb;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\RemembersConversations as RemembersConversationsContract;
use Laravel\Ai\Promptable;
use Stringable;
class ContentAgent implements Agent, HasTools, RemembersConversationsContract
{
use Promptable;
use RemembersConversations;
public function instructions(): Stringable|string
{
return <<<'PROMPT'
You research a topic and draft a blog post in our house style.
Publish only when you are confident the draft is finished.
PROMPT;
}
public function tools(): iterable
{
return [
new SearchWeb,
(new PublishArticle)->requireApproval('Publishing is public and irreversible.'),
];
}
}
```
`requireApproval()` is Laravel AI's own API. The harness picks it up and turns the pause into a durable record.
### The tool
```php
namespace App\Ai\Tools;
use Clutch\Laravel\Contracts\IdempotentTool;
use Clutch\Laravel\Contracts\SensitiveTool;
use Clutch\Laravel\Data\ToolInvocation;
use Clutch\Laravel\Enums\ToolSensitivity;
use App\Models\Article;
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 PublishArticle implements Approvable, IdempotentTool, SensitiveTool, Tool
{
use InteractsWithApprovals;
public function description(): Stringable|string
{
return 'Publish a drafted article to the public blog.';
}
public function schema(JsonSchema $schema): array
{
return [
'article_id' => $schema->integer()->description('The draft to publish.')->required(),
];
}
public function sensitivity(): ToolSensitivity
{
return ToolSensitivity::Irreversible;
}
public function idempotencyKey(ToolInvocation $invocation): string
{
// Key the side effect, not the call. Two retries of "publish 42" have
// to collide even though their tool-call IDs differ.
return "publish-article:{$invocation->arguments['article_id']}";
}
public function handle(Request $request): Stringable|string
{
$article = Article::findOrFail($request['article_id']);
$article->publish();
return "Published \"{$article->title}\".";
}
}
```
### Starting the work
```php
Route::post('/content/research', function (Request $request) {
$session = Clutch::agent(ContentAgent::class)
->for($request->user())
->tenant($request->user()->currentTeam)
->name("Blog post: {$request->topic}")
->create();
$run = $session->queue("Research {$request->topic} and draft a post for our blog.");
return response()->accepted([
'session_id' => $session->id,
'run_id' => $run->id,
]);
});
```
The HTTP request returns immediately. A queue worker picks the run up, and the agent researches, drafts, and reaches `publish_article`. At that point the run pauses, a checkpoint is written, and the worker exits normally. Nothing is held open waiting for a human.
### Notifying the approver
```php
// app/Providers/AppServiceProvider.php
use Clutch\Laravel\Events\ApprovalRequested;
Event::listen(ApprovalRequested::class, function (ApprovalRequested $event) {
$event->approval->session->participant?->notify(
new AgentAwaitingApproval($event->approval)
);
});
```
### The inbox
```php
Route::get('/approvals', function (Request $request) {
return view('approvals.index', [
'approvals' => Clutch::pendingApprovalsFor($request->user()),
]);
});
```
```blade
@foreach ($approvals as $approval)
@endforeach
```
The bundled endpoints authorize by participant, resolve idempotently, and re-queue the run once every decision is in. If you would rather own that surface:
```php
Route::post('/approvals/{approval}', function (Request $request, string $approvalId) {
$approval = Approval::findOrFail($approvalId);
$run = Clutch::run($approval->run_id)->authorizeFor($request->user());
$request->boolean('approved')
? $run->approve($approvalId, $request->reason, $request->user())
: $run->reject($approvalId, $request->reason, $request->user());
Clutch::coordinator()->resumeAfterApproval($run->refresh());
return back();
});
```
What that buys you: the decision survives a deploy, a double click cannot publish twice, reversing a decision raises instead of silently winning, and the approver and their reason both end up in the audit history.
---
## 2. A live progress UI that survives a refresh
A long run, a progress bar, and a user who refreshes the page halfway through.
### Server
Two endpoints. Start the work, then stream it:
```php
Route::post('/runs', function (Request $request) {
$session = Clutch::session($request->session_id)->authorizeFor($request->user());
return ['run_id' => $session->queue($request->prompt)->id];
});
```
The event stream endpoint ships with the package:
```
GET /api/clutch/runs/{run}/events?after={cursor}
```
### Client
```js
function follow(runId, onEvent) {
// Resume from where we left off, not from zero.
let cursor = Number(localStorage.getItem(`run:${runId}`) ?? 0)
const seen = new Set()
const source = new EventSource(
`/api/clutch/runs/${runId}/events?after=${cursor}`
)
// Every frame arrives here, because the stream does not name its SSE
// events. The type is on the payload instead.
source.onmessage = (message) => {
if (message.data === '[DONE]') return source.close()
const event = JSON.parse(message.data)
// Delivery is at least once; storage is exactly once and ordered.
const key = `${event.run_id}:${event.sequence}`
if (seen.has(key)) return
seen.add(key)
cursor = event.sequence
localStorage.setItem(`run:${runId}`, cursor)
onEvent(event)
}
// A connection is held for a bounded time, then asks you to come back.
source.addEventListener('timeout', () => {
source.close()
follow(runId, onEvent)
})
}
follow(runId, (event) => {
switch (event.type) {
case 'text.delta': appendText(event.payload.delta); break
case 'tool.call.requested': showStep(`Running ${event.payload.tool}…`); break
case 'tool.call.completed': completeStep(event.payload.tool); break
case 'approval.requested': showApprovalPrompt(event.payload); break
case 'artifact.created': addDownload(event.payload); break
case 'run.completed': finish(event.payload.text); break
case 'run.failed': showError(event.payload.message); break
}
})
```
Refresh the page and the stream picks up at the stored cursor without a gap or a repeat. Close the laptop for an hour and it still works, because the events are rows in a table rather than a socket.
### Already using the Vercel AI SDK?
Stream a synchronous run straight into `useChat`:
```php
Route::post('/chat', function (Request $request) {
return Clutch::session($request->session_id)
->authorizeFor($request->user())
->stream($request->message)
->usingVercelDataProtocol();
});
```
The full durable event history is still behind it. The protocol is a view over the same recorded events rather than a separate path.
---
## 3. Multi-tenant agents
Scope a session to a team and the harness enforces it on every lookup, route, and broadcast channel:
```php
$session = Clutch::agent(SupportAgent::class)
->for($request->user())
->tenant($request->user()->currentTeam)
->create();
```
Querying stays ordinary Eloquent:
```php
use Clutch\Laravel\Models\Session;
$teamSessions = Session::query()
->forTenant($team)
->withStatus(SessionStatus::Ready, SessionStatus::AwaitingApproval)
->latest()
->get();
```
Every packaged route authorizes against the session's participant, so a run belonging to another user is unreachable rather than merely hidden from a list. Broadcast channels authorize the same way:
```php
// Registered for you when broadcasting is on.
Broadcast::channel('clutch.run.{runId}', fn ($user, $runId) => /* participant check */);
```
Tenant scoped agents usually want tenant scoped tools. The ambient run context carries the scope so you do not have to thread it through constructors:
```php
use Clutch\Laravel\Runtime\RunContext;
public function handle(Request $request): string
{
$context = RunContext::current();
$team = Team::findOrFail($context->invocationFor('search_tickets', 'x')->tenantId);
return $team->tickets()->search($request['query'])->take(5)->toJson();
}
```
---
## 4. Capping spend per customer plan
Tell the harness what your models cost:
```php
// config/clutch.php
'pricing' => [
'anthropic:claude-sonnet-4-5' => ['input' => 3.00, 'output' => 15.00],
'anthropic:claude-haiku-4-5' => ['input' => 1.00, 'output' => 5.00],
'openai:gpt-5' => ['input' => 1.25, 'output' => 10.00],
],
```
Then set a budget per plan:
```php
use Clutch\Laravel\ValueObjects\RunBudget;
$budget = match ($user->plan) {
'free' => new RunBudget(maxSteps: 10, maxTokens: 50_000, maxCostUsd: 0.25),
'pro' => new RunBudget(maxSteps: 40, maxTokens: 250_000, maxCostUsd: 5.00),
default => new RunBudget(maxDurationSeconds: 1800),
};
$session = Clutch::agent(ResearchAgent::class)
->for($user)
->budget($budget)
->create();
```
A run that hits the ceiling stops at `budget_exceeded`, with an event naming the limit that ran out:
```json
{
"type": "run.budget_exceeded",
"payload": {
"limit": "max_cost_usd",
"max": 0.25,
"used": 0.2513,
"usage": { "steps": 7, "total_tokens": 41208, "cost_usd": 0.2513 }
}
}
```
Bill from the same numbers:
```php
Event::listen(ClutchEventRecorded::class, function ($event) {
if ($event->type !== 'run.completed') {
return;
}
$run = Clutch::run($event->runId);
$run->session->participant?->recordUsage(
tokens: $run->usage()->totalTokens(),
costUsd: $run->usage()->costUsd,
);
});
```
Usage carries across attempts, so a run that fails and retries three times cannot spend the cap three times.
---
## 5. A tool that must never double-charge
This is the failure that is hardest to reason about. The tool succeeded, and then the worker died before recording that it had.
```php
class ChargeCustomer implements IdempotentTool, SensitiveTool, Tool
{
public function idempotencyKey(ToolInvocation $invocation): string
{
// The run ID scopes it to this piece of work. The invoice ID makes two
// charges for the same invoice collide, even across retries.
return "charge:{$invocation->runId}:{$invocation->arguments['invoice_id']}";
}
public function sensitivity(): ToolSensitivity
{
return ToolSensitivity::Irreversible;
}
public function handle(Request $request): Stringable|string
{
$invoice = Invoice::findOrFail($request['invoice_id']);
// Pass the key to the payment provider as well. The ledger protects
// you inside the harness, and this protects you if the HTTP request
// itself is what got retried.
$charge = Stripe::charge($invoice, idempotencyKey: $this->keyFor($invoice));
return "Charged {$invoice->formattedTotal()} ({$charge->id}).";
}
}
```
The harness writes the key and a `pending` row before calling `handle()`, then updates it to `completed` with the result afterwards. A retry finds the completed row and returns the stored result without calling `handle()` again.
Inspect the ledger like any other table:
```php
use Clutch\Laravel\Models\ToolExecution;
ToolExecution::query()
->where('tool_name', 'charge_customer')
->where('status', ToolExecution::FAILED)
->latest()
->get();
```
A tool with no idempotency contract still gets recorded for audit, but the harness makes no duplicate suppression claim for it. Only you know what counts as the same action for your side effect.
---
## 6. Nightly batch runs
```php
// routes/console.php
Schedule::call(function () {
Team::query()->whereHas('subscription')->each(function (Team $team) {
$session = Clutch::agent(ReportingAgent::class)
->for($team->owner)
->tenant($team)
->name("Weekly report: {$team->name}")
->onQueue('reports')
->budget(new RunBudget(maxCostUsd: 2.00, maxDurationSeconds: 600))
->create();
$session->queue('Produce this week\'s performance report as a PDF.');
});
})->weeklyOn(1, '06:00');
```
Have the tool attach the PDF:
```php
use Clutch\Laravel\Artifacts\Artifact;
use Clutch\Laravel\Runtime\RunContext;
public function handle(Request $request): Stringable|string
{
$pdf = Pdf::loadView('reports.weekly', ['data' => $this->gather()])->output();
$artifact = RunContext::current()->artifacts()->add(
Artifact::fromContents($pdf, "reports/{$this->team->id}/".now()->toDateString().'.pdf')
->name('Weekly performance report')
->mimeType('application/pdf')
->metadata(['week_of' => now()->startOfWeek()->toDateString()])
);
return "Report ready: {$artifact->id}";
}
```
Then hand it to the customer:
```php
Route::get('/reports/{artifact}', function (Request $request, string $artifactId) {
// The packaged route already authorizes and prefers a temporary URL.
return redirect("/api/clutch/artifacts/{$artifactId}");
});
```
These are registered for you, but they are worth knowing about:
```
agent-clutch:reap every five minutes recovers runs whose worker vanished
agent-clutch:expire-approvals every five minutes closes stale approval windows
agent-clutch:prune daily at 03:10 applies retention windows
```
---
## 7. A structured grading agent
```php
class RubricAgent implements Agent, HasStructuredOutput, RemembersConversationsContract
{
use Promptable;
use RemembersConversations;
public function instructions(): Stringable|string
{
return 'You grade drafts against our content rubric. Be strict and specific.';
}
public function schema(JsonSchema $schema): array
{
return [
'score' => $schema->integer()->description('0-100.')->required(),
'strengths' => $schema->array()->items($schema->string())->required(),
'fixes' => $schema->array()->items($schema->string())->required(),
];
}
}
```
```php
$result = $session->prompt("Grade this draft:\n\n{$draft}");
$draft->update([
'score' => $result->structured['score'],
'feedback' => $result->structured['fixes'],
]);
```
The validated structured value is stored on the terminal run record and emitted with `run.completed`, so it is queryable later without re-running anything:
```php
Run::query()
->where('session_id', $session->id)
->terminal()
->get()
->map(fn (Run $run) => $run->structured_output['score'] ?? null);
```
> Structured agents use Laravel AI's buffered prompt path, since that is where validated structured output comes from. Events are synthesized from the completed response rather than streamed token by token.
---
## 8. Testing the whole thing
```php
use Clutch\Laravel\Artifacts\Artifact;
use Clutch\Laravel\Facades\Clutch;
use Clutch\Laravel\Runtime\ClutchResult;
it('drafts a post and waits for a human before publishing', function () {
$fake = Clutch::fake([
ClutchResult::text('Here is the draft.')
->withToolCall('search_web', ['q' => 'competitor pricing'], '12 results'),
ClutchResult::awaitingApproval(
tool: 'publish_article',
arguments: ['article_id' => 42],
reason: 'Publishing is irreversible.',
),
]);
$user = User::factory()->create();
$session = Clutch::agent(ContentAgent::class)->for($user)->create();
$session->queue('Research AI receptionists and draft a post.');
$session->refresh()->queue('Looks good, publish it.');
$fake->assertApprovalRequested('publish_article');
// The tool has not run: nothing was published.
expect(Article::find(42)->published_at)->toBeNull();
});
it('publishes once the approval lands, and only once', function () {
$fake = Clutch::fake([
ClutchResult::awaitingApproval(tool: 'publish_article', arguments: ['article_id' => 42]),
ClutchResult::text('Published.'),
]);
$user = User::factory()->create();
$session = Clutch::agent(ContentAgent::class)->for($user)->create();
$paused = $session->prompt('Publish article 42.');
$approval = $paused->pendingApprovals->first();
// A second request, in a different process, with no memory of the first.
$this->actingAs($user)
->post("/api/clutch/runs/{$paused->run->id}/approvals/{$approval->id}/approve")
->assertOk();
// Double-click.
$this->actingAs($user)
->post("/api/clutch/runs/{$paused->run->id}/approvals/{$approval->id}/approve")
->assertOk();
$fake->assertApproved('publish_article');
$fake->assertRunCompleted();
expect($paused->run->refresh()->events()->where('type', 'approval.resolved')->count())->toBe(1);
});
```
`Clutch::fake()` swaps in a deterministic driver and runs queued work inline. Everything else stays real: the coordinator, the state machine, the event store, approvals, artifacts, the ledger, and the HTTP routes. You are testing against the actual runtime with the provider removed.
### Testing against real Laravel AI
To exercise the real driver, fake the agent instead of the harness using Laravel AI's own fake gateway:
```php
it('runs the real driver', function () {
ResearchAgent::fake(['Their weakest flank is onboarding.']);
$session = Clutch::agent(ResearchAgent::class)->for($this->user)->create();
expect($session->prompt('Research competitors.')->text)
->toBe('Their weakest flank is onboarding.');
});
```
This is the more faithful test. Event translation, conversation persistence, and approval mapping are all real, and only the model call is faked.