Quick Start
- Introduction
- Installation
- Creating an Agent
- Creating a Callback Step
- Defining the Workflow
- Registering the Workflow
- Starting Runs
- Resuming Runs
- Handling Failures
- Where to Next
Introduction
This guide builds a small, real feature end to end: when a support ticket arrives, an agent drafts a reply, a human reviews the draft, and only then does the application send it. Three steps, one of which is "wait for a person", which is exactly what a plain request cycle cannot do.
Installation
Agent Workflows requires PHP 8.3+, Laravel 12 or 13, and laravel/ai ^0.10.3. You may install the package via Composer, then publish its configuration file and run the migrations:
composer require timmcleod/agent-workflows
php artisan vendor:publish --tag=agent-workflows-config
php artisan migrateCreating an Agent
Steps that talk to the AI are ordinary laravel/ai agent classes. Nothing package-specific is required. The agent defines how it behaves, while the workflow decides what to ask it:
// app/Agents/DraftReplyAgent.php
namespace App\Agents;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
use Stringable;
class DraftReplyAgent implements Agent
{
use Promptable;
public function instructions(): Stringable|string
{
return 'Draft a friendly, concise support reply.';
}
}Creating a Callback Step
Steps that do not need an AI are plain invokable classes. They receive the workflow's state, do their work, and return the state:
// app/Workflows/SendReply.php
namespace App\Workflows;
use App\Models\Ticket;
use TimMcLeod\AgentWorkflows\WorkflowState;
class SendReply
{
public function __invoke(WorkflowState $state): WorkflowState
{
$ticket = Ticket::findOrFail($state->get('ticket_id'));
// The agent's draft was checkpointed under its step id;
// the reviewer's edits arrive via resume().
$ticket->sendReply($state->get('final_reply') ?? $state->get('steps.DraftReplyAgent.text'));
return $state->set('sent', true);
}
}Defining the Workflow
Every workflow is a class. You may generate one using the make:agent-workflow Artisan command:
php artisan make:agent-workflow TicketReplyWithin the generated class, describe the workflow's steps in the build method:
// app/AgentWorkflows/TicketReply.php
namespace App\AgentWorkflows;
use App\Agents\DraftReplyAgent;
use App\Workflows\SendReply;
use TimMcLeod\AgentWorkflows\Workflow;
use TimMcLeod\AgentWorkflows\WorkflowDefinition;
use TimMcLeod\AgentWorkflows\WorkflowState;
class TicketReply extends Workflow
{
public function build(WorkflowDefinition $workflow): WorkflowDefinition
{
return $workflow
->step(
DraftReplyAgent::class,
'Draft a friendly, concise reply to this ticket: {{ ticket_message }}'
)
->awaitHuman(
reason: 'Review the drafted reply',
schema: ['final_reply' => 'required|string']
)
->step(SendReply::class);
}
}The prompt is the step's second argument, and its {{ ticket_message }} template pulls the run's input from the workflow state. Agent Steps covers every prompt form.
Registering the Workflow
Next, list the class in the workflows array of your config/agent-workflows.php configuration file:
'workflows' => [
App\AgentWorkflows\TicketReply::class,
],Definitions are registered at boot on every process, since queue workers need them too. Learn more in registration.
Starting Runs
You may start a run from anywhere in your application (a controller, a job, an Artisan command) using the workflow's static start method:
// routes/web.php (or a controller)
Route::post('/tickets/{ticket}/draft-reply', function (Ticket $ticket, Request $request) {
$run = TicketReply::start([
'ticket_id' => $ticket->id,
'ticket_message' => $ticket->message,
], participant: $request->user());
return ['run_id' => $run->id, 'status' => $run->status];
});The response returns instantly with a status of pending. Nothing has executed yet, and nothing will until a queue worker runs:
php artisan queue:workThe worker picks up step 1 as a job, the agent drafts the reply, the checkpoint is saved, and the run parks itself at the awaitHuman step with a status of awaiting_human. It will sit there through deploys, restarts, and weekends.
Resuming Runs
Your review UI reads the run and shows the draft:
Route::get('/runs/{run}', function (WorkflowRun $run) {
return [
'status' => $run->status, // "awaiting_human"
'draft' => $run->state['steps']['DraftReplyAgent']['text'] ?? null,
'waiting_for' => $run->interrupts()->whereNull('resolved_at')->value('reason'),
];
});Once the human responds, you may resume the run with their answer:
Route::post('/runs/{run}/approve', function (WorkflowRun $run, Request $request) {
$run = $run->resume([
'final_reply' => $request->input('final_reply'), // validated against the schema
], by: $request->user());
return ['status' => $run->status];
});The resume method validates the payload against the schema from the awaitHuman step, merges it into state, and queues the next step. The worker runs SendReply, and the run completes.
Handling Failures
Suppose the mail provider was down and SendReply threw. The run is now failed, with the draft and the reviewer's edits safely checkpointed:
$run->failed_step; // "SendReply"
$run->failure_reason; // the exception message
$run->retry(); // re-queues SendReply onlyThe retry method re-runs only the failed step. The agent never re-runs, and no tokens are re-billed.
That's the whole loop: agents and plain classes as steps, one Workflow class listed in config, start from anywhere, a queue worker doing the work, resume when humans answer, and retry when things break.
Where to Next
Defining Workflows documents the full definition API (conditions, parallel fan-outs, loops, aliases, drift protection) and the documentation index maps everything else, from prompts and state to observability and production operations.