Skip to content

Quick Start

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:

bash
composer require timmcleod/agent-workflows

php artisan vendor:publish --tag=agent-workflows-config
php artisan migrate

Creating 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:

php
// 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:

php
// 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:

bash
php artisan make:agent-workflow TicketReply

Within the generated class, describe the workflow's steps in the build method:

php
// 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:

php
'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:

php
// 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:

bash
php artisan queue:work

The 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:

php
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:

php
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:

php
$run->failed_step;      // "SendReply"
$run->failure_reason;   // the exception message
$run->retry();          // re-queues SendReply only

The 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.

Released under the MIT License.