Building a ReAct Chat Agent with the Laravel AI SDK
This tutorial walks through how to build a streaming, chat assistant on top of
the Laravel AI SDK (laravel/ai).
Stack: PHP 8.5 · Laravel 13 · Livewire 4 · Laravel AI SDK
1. What is the ReAct pattern?
A plain language model is a "one-shot" text predictor: you give it a question, it
gives you an answer from what it already knows. That breaks down the moment the
question depends on something the model can't know — the current time, today's
exchange rate, an exact arithmetic result, or a fact that changed after training.
ReAct (short for Reasoning + Acting) fixes this by letting the model
work in a loop instead of answering in one go:
- Reason — the model thinks about what the question needs.
- Act — if it needs outside information, it calls a tool (a function you provide) instead of guessing.
- Observe — your tool runs and returns a result; that result is fed back to the model as a new observation.
- Repeat — the model reasons about the observation and either calls another tool or produces its final answer.
A worked example, "What time is it in Tokyo right now?":
User: What time is it in Tokyo right now?
Reason: I don't actually know the current time. I should call a clock tool.
Act: current_datetime(timezone: "Asia/Tokyo")
Observe: Saturday, June 6, 2026 at 11:42 PM (Asia/Tokyo)
Reason: I now have the real local time. I can answer.
Answer: It's currently 11:42 PM on Saturday, June 6, 2026 in Tokyo.
The key idea: the model never fakes the time. It recognises the gap, reaches
for a tool, and reasons over the real result. That loop — reason, act, observe,
repeat — is the whole pattern.
2. What is the Laravel AI SDK?
The Laravel AI SDK (laravel/ai) is a unified, Laravel-flavoured API for talking
to AI providers (Anthropic, OpenAI, Gemini, Groq, Ollama, and more). Instead of
hand-rolling provider-specific HTTP calls, you describe an Agent as a PHP
class and the SDK handles the rest.
The four concepts you need for this tutorial:
-
Agent — a class that bundles a system prompt (
instructions()), a model choice, and a set of tools. It is the thing you "prompt". -
Tool — a small class implementing
Laravel\Ai\Contracts\Toolthat the model is allowed to call. Each tool advertises a name, a description, and a parameter schema, and exposes ahandle()method that does the work. - Streaming — instead of waiting for the whole answer, you can iterate over the response as it is generated, receiving text fragments and tool-call events in real time.
- Conversation memory — the SDK can automatically persist every turn to the database and replay history so the agent has context on the next message.
Crucially, the SDK runs the ReAct loop for you. You define the tools and a
step limit; the SDK handles asking the model what it wants to do, executing the
chosen tool, feeding the observation back, and looping until the model is done.
Built on Prism
The Laravel AI SDK isn't talking to providers entirely on its own — it's a
higher-level abstraction built on top of Prism.
Taylor Otwell has described the relationship as being like Eloquent vs the
Query Builder: Prism is the lower-level AI integration layer, and the Laravel
AI SDK is the higher-level, Laravel-native framework layered on top of it,
adding Agents, Tools, conversation memory, structured output, streaming, and
testing helpers.
This isn't just conceptual — under the hood the laravel/ai Composer package
pulls in prism-php/prism as a dependency. When you call $agent->stream(), the
SDK is ultimately driving Prism, which drives the provider's HTTP API.
A rough mapping between the two layers:
| Prism | Laravel AI SDK |
|---|---|
| Direct LLM calls | Agent classes |
| Provider abstraction | Agent architecture |
| Function/tool calling | Tool classes |
| Structured output | Structured output + schemas |
| Streaming | Streaming |
| Conversation handling | Built-in conversation memory |
| Testing helpers | Laravel-native testing & fakes |
| Lower-level | Higher-level |
So where does each layer fit in the request path? Picturing the full stack helps:
Browser (Livewire UI)
└─▶ Laravel route / Livewire component
└─▶ Laravel AI SDK Agent (instructions, tools, memory)
└─▶ Prism (provider abstraction, tool calling)
└─▶ OpenAI / Anthropic / …
For application development, reach for the Laravel AI SDK — it's the layer
designed for building features like the chat in this tutorial. It's still worth
understanding Prism well enough to know what's happening underneath, and you can
always drop down to Prism directly if you ever need something the SDK doesn't expose yet — the same way you'd occasionally reach past Eloquent for the Query
Builder.
3. How this app fits together
The app is a single-page chat (think a minimal ChatGPT clone). A Livewire
component takes the user's prompt, hands it to a ChatAgent, and streams the
agent's reply token-by-token into the browser.
┌──────────────┐ prompt ┌────────────────┐ reason/act ┌──────────────┐
│ Livewire UI │ ──────────▶ │ ChatAgent │ ─────────────▶ │ Provider │
│ (Blade + │ │ (Laravel AI │ ◀───────────── │ (Claude 4.6) │
│ Alpine.js) │ │ SDK agent) │ tool calls └──────────────┘
└──────────────┘ └───────┬────────┘
▲ │ executes
│ wire:stream ▼
│ (TextDelta / ToolCall) ┌──────────────────────────────────────┐
└────────────────────────── │ Tools: Calculator · CurrentDateTime ·│
│ WikipediaLookup · WebSearch │
└──────────────────────────────────────┘
The pieces, by file:
| Concern | File |
|---|---|
| The agent | app/Ai/Agents/ChatAgent.php |
| Tools | app/Ai/Tools/*.php |
| Chat UI + streaming | resources/views/components/chat/⚡main.blade.php |
| Conversation owner | app/Models/User.php |
| Persistence schema | database/migrations/..._create_agent_conversations_table.php |
| Config | config/ai.php |
| Tests |
tests/Feature/ChatTest.php, tests/Unit/*
|
4. Configuration
The default provider and credentials live in your environment. This app uses
Anthropic:
# .env
AI_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-...
config/ai.php reads those values. The default provider falls back to
anthropic and the Anthropic driver is wired to the API key:
// config/ai.php
'default' => env('AI_PROVIDER', 'anthropic'),
'providers' => [
// ...
'anthropic' => [
'driver' => 'anthropic',
'key' => env('ANTHROPIC_API_KEY'),
'url' => env('ANTHROPIC_URL', 'https://api.anthropic.com/v1'),
],
// ...
],
You rarely reference the string 'anthropic' in code, though. The SDK ships a
type-safe Lab enum (Laravel\Ai\Enums\Lab) that you attach to an agent — we'll
see that next.
5. Building the agent
The whole agent is one small class. Read it once, then we'll break it down.
// app/Ai/Agents/ChatAgent.php
use App\Ai\Tools\Calculator;
use App\Ai\Tools\CurrentDateTime;
use App\Ai\Tools\WikipediaLookup;
use Laravel\Ai\Attributes\MaxSteps;
use Laravel\Ai\Attributes\Model;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Attributes\Temperature;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
use Laravel\Ai\Providers\Tools\WebSearch;
use Stringable;
#[Provider(Lab::Anthropic)]
#[Model('claude-sonnet-4-6')]
#[Temperature(0.7)]
#[MaxSteps(8)]
class ChatAgent implements Agent, Conversational, HasTools
{
use Promptable, RemembersConversations;
public function instructions(): Stringable|string
{
return <<<'PROMPT'
You are a friendly, knowledgeable assistant that answers using the ReAct pattern:
Reason about the question, Act by calling a tool when it helps, observe the
result, then continue until you can give a clear final answer.
Guidelines:
- Think step by step, but keep your final answer concise and well formatted (Markdown).
- Use the `calculator` tool for any arithmetic instead of computing in your head.
- Use the `current_datetime` tool whenever the user asks about the current date or time.
- Use the `wikipedia_lookup` tool for factual background on a specific topic, person, or place.
- Use web search for recent events or anything that may have changed after your training.
- If a tool fails or returns nothing useful, say so honestly rather than guessing.
PROMPT;
}
public function tools(): iterable
{
return [
new Calculator,
new CurrentDateTime,
new WikipediaLookup,
(new WebSearch)->max(5),
];
}
}
The attributes
PHP attributes configure the agent declaratively:
-
#[Provider(Lab::Anthropic)]— which provider to talk to, using the type-safeLabenum rather than a magic string. -
#[Model('claude-sonnet-4-6')]— the specific model. -
#[Temperature(0.7)]— sampling randomness; 0.7 is a balanced default for a conversational assistant. -
#[MaxSteps(8)]— this is the ReAct loop bound. Each "step" is one reason→act→observe cycle. With8, the agent may call tools and reason up to eight times before it must produce a final answer. This is your safety valve against a model that loops forever calling tools.
The contracts and traits
class ChatAgent implements Agent, Conversational, HasTools
{
use Promptable, RemembersConversations;
-
implements Agent— marks the class as an agent. -
implements HasTools— declares that this agent exposes tools (viatools()). -
implements Conversational— declares that this agent participates in persisted, multi-turn conversations. -
use Promptable— adds theprompt()andstream()methods you call to run the agent. -
use RemembersConversations— automatically persists each user/assistant turn and replays prior history so the model has context. You get conversation memory for free.
The system prompt
instructions() returns the system prompt. Notice it does two jobs: it tells the
model to follow the ReAct pattern, and it gives concrete guidance on which tool
to prefer for which kind of question. Good tool descriptions plus clear prompt
guidance are what make the model reach for the right tool at the right time.
The tools
tools() returns the list the model is allowed to call. Three are custom classes
in this app (Calculator, CurrentDateTime, WikipediaLookup); the fourth,
WebSearch, is a built-in provider tool shipped by the SDK — here we cap it
to five results with ->max(5). We'll write a tool next.
6. Writing tools
A tool is any class implementing Laravel\Ai\Contracts\Tool. The contract is
four methods: name(), description(), schema(), and handle(). The model
reads the name, description, and schema to decide whether and how to call the
tool; handle() does the actual work and returns an observation string.
A minimal tool: the current date and time
The clock is the cleanest example of an "observation" tool — the model simply
cannot know the real current time, so it has to ask.
// app/Ai/Tools/CurrentDateTime.php
use Carbon\CarbonImmutable;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
class CurrentDateTime implements Tool
{
public function name(): string
{
return 'current_datetime';
}
public function description(): Stringable|string
{
return 'Get the current date and time. Optionally pass an IANA timezone '
.'(e.g. "Asia/Tokyo", "America/New_York") to get the local time there.';
}
public function schema(JsonSchema $schema): array
{
return [
'timezone' => $schema->string()
->description('An IANA timezone identifier such as "Europe/Paris". Defaults to UTC.'),
];
}
public function handle(Request $request): Stringable|string
{
$timezone = $request->string('timezone', 'UTC')->toString();
if (! in_array($timezone, timezone_identifiers_list(), true)) {
return "Unknown timezone \"{$timezone}\". Please use an IANA identifier like \"Asia/Tokyo\".";
}
$now = CarbonImmutable::now($timezone);
return $now->format('l, F j, Y \a\t g:i A').' ('.$timezone.')';
}
}
Things worth noticing:
-
name()is the identifier the model uses when it decides to call the tool. Keep it short and snake_case. -
description()is sales copy aimed at the model. The clearer you describe when to use the tool and what each parameter means, the more reliably the model calls it correctly. -
schema()declares the parameters using a fluent JSON-schema builder. Here,timezoneis an optional string (no->required()), with its own description so the model knows to pass an IANA identifier. -
handle(Request $request)receives the model's arguments as aRequestobject.$request->string('timezone', 'UTC')reads the argument with a default. The returned string is what the model "observes". Note we validate the timezone and return a helpful message instead of throwing — the model can read that message and recover.
A tool that calls an external API
Tools can do real I/O. WikipediaLookup reaches out to Wikipedia's REST API and
returns a summary — and importantly, it handles every failure path gracefully so
the model always gets a usable observation.
// app/Ai/Tools/WikipediaLookup.php
public function schema(JsonSchema $schema): array
{
return [
'topic' => $schema->string()
->description('The Wikipedia article title to summarize, e.g. "Great Barrier Reef".')
->required(),
];
}
public function handle(Request $request): Stringable|string
{
$topic = $request->string('topic')->trim()->toString();
if ($topic === '') {
return 'No topic was provided to look up.';
}
try {
$response = Http::acceptJson()
->withHeaders(['User-Agent' => 'LaravelAiSdkDemo/1.0 (tutorial)'])
->timeout(15)
->get('https://en.wikipedia.org/api/rest_v1/page/summary/'.rawurlencode($topic));
} catch (Throwable $e) {
return "Wikipedia lookup for \"{$topic}\" failed: {$e->getMessage()}";
}
if ($response->status() === 404) {
return "No Wikipedia article was found for \"{$topic}\".";
}
if ($response->failed()) {
return "Wikipedia lookup for \"{$topic}\" failed with status {$response->status()}.";
}
$extract = $this->jsonString($response, 'extract');
if ($extract === '') {
return "No summary is available for \"{$topic}\".";
}
$title = $this->jsonString($response, 'title', $topic);
$url = $this->jsonString($response, 'content_urls.desktop.page');
return trim("**{$title}**\n{$extract}".($url !== '' ? "\nSource: {$url}" : ''));
}
The pattern here is the lesson: a tool should never throw raw exceptions at the
model. A timeout, a 404, an empty body — each becomes a plain-English string
the model can reason about ("the lookup failed, I'll tell the user honestly"),
exactly as the system prompt instructs. Note topic is ->required(), and the
tool returns lightly-formatted Markdown including a source link.
A tool that must be safe: the calculator
Calculator lets the model do exact arithmetic. The interesting part is what it
doesn't do — it never calls eval(). Instead it tokenizes the expression and
walks a tiny recursive-descent grammar that only understands numbers, the
operators + - * / % ^, parentheses, and unary minus. Anything else is rejected.
// app/Ai/Tools/Calculator.php
public function schema(JsonSchema $schema): array
{
return [
'expression' => $schema->string()
->description('The arithmetic expression to evaluate, e.g. "3 * (4 + 5)".')
->required(),
];
}
public function handle(Request $request): Stringable|string
{
$expression = $request->string('expression')->toString();
try {
$result = $this->evaluate($expression);
} catch (Throwable $e) {
return "Could not evaluate \"{$expression}\": {$e->getMessage()}";
}
// Render integers without a trailing ".0" for nicer output.
$formatted = $result == (int) $result
? (string) (int) $result
: (string) $result;
return "{$expression} = {$formatted}";
}
The takeaway: model-supplied input is untrusted. A naive eval($expression)
would be a remote code execution hole, because the model (or a user steering it)
controls that string. The private evaluate() method parses safely instead. The
full parser lives in app/Ai/Tools/Calculator.php.
Built-in provider tools
Not every tool is yours to write. (new WebSearch)->max(5) in the agent's
tools() is a provider tool from Laravel\Ai\Providers\Tools\WebSearch — the
provider runs the search natively. You compose it alongside your custom tools in
the same list, and the model treats them all the same way.
7. Streaming the answer to the browser
The chat lives in a Livewire single-file component. Its send() method is where
the agent meets the UI — and where streaming happens.
// resources/views/components/chat/⚡main.blade.php (component class)
use App\Ai\Agents\ChatAgent;
use Laravel\Ai\Streaming\Events\TextDelta;
use Laravel\Ai\Streaming\Events\ToolCall;
public function send(string $prompt): void
{
$prompt = trim($prompt);
if ($prompt === '') {
return;
}
// The ReAct loop streams over a long-lived request (tool calls, web
// search, multi-step reasoning); lift the request time limit so the
// response isn't truncated mid-stream by PHP's max_execution_time.
set_time_limit(0);
$agent = $this->conversationId === null
? ChatAgent::make()->forUser($this->user())
: ChatAgent::make()->continue($this->conversationId, as: $this->user());
$response = $agent->stream($prompt);
$answer = '';
foreach ($response as $event) {
if ($event instanceof ToolCall) {
$this->stream(to: 'status', content: 'Using '.$event->toolCall->name.'…', replace: true);
} elseif ($event instanceof TextDelta) {
// Render the accumulated answer as Markdown on each delta so the
// live stream shows formatted text (not raw Markdown), matching
// how the persisted message is rendered once the turn completes.
$answer .= $event->delta;
$this->stream(
to: 'answer',
content: Str::markdown($answer, ['html_input' => 'escape', 'allow_unsafe_links' => false]),
replace: true,
);
}
}
// After the stream finishes, the SDK has persisted the conversation.
$this->conversationId = $response->conversationId;
unset($this->messages);
// Tell the sidebar to refresh its history list and highlight this
// (possibly brand-new) conversation.
$this->dispatch('conversation-updated', id: $this->conversationId);
}
Step by step:
set_time_limit(0)— a ReAct turn can involve several tool calls and a
web search, so it may outlast PHP's defaultmax_execution_time. Lifting the
limit prevents the stream from being cut off mid-answer.Starting vs continuing a conversation —
ChatAgent::make()builds the
agent.->forUser($user)starts a new conversation owned by that user;
->continue($conversationId, as: $user)resumes an existing one so the model
sees prior history. The component just tracks$this->conversationId.$agent->stream($prompt)— runs the agent and returns an iterable stream
instead of a single blob. ThePromptabletrait provides this.-
The event loop — iterating the response yields typed events:
-
ToolCall— the model decided to act. We surface a small "Using calculator…" status via$this->stream(to: 'status', ...)so the user can see the agent reaching for a tool.$event->toolCall->nameis the tool'sname(). -
TextDelta— a fragment of the final answer. We accumulate fragments into$answer, render the running total as Markdown, and push it to theanswertarget withreplace: true(replace, not append, because we re-render the whole accumulated Markdown each time).
-
After the loop — because of
RemembersConversations, the SDK has already
persisted both the user prompt and the assistant reply. We read the (possibly
brand-new)$response->conversationId, drop the cachedmessagescomputed
property, and notify the sidebar to refresh.
Where the stream lands in the markup
$this->stream(to: 'answer', ...) and to: 'status' target named regions in the
Blade view via wire:stream:
{{-- resources/views/components/chat/⚡main.blade.php (markup) --}}
<div x-show="streaming" x-cloak class="flex flex-col gap-2">
<div x-ref="status" wire:stream="status" class="text-xs font-medium text-accent"></div>
<div class="max-w-[90%] text-[15px]">
<span x-show="!hasAnswer" class="inline-flex gap-1 align-middle">
{{-- animated "typing" dots while we wait for the first token --}}
</span>
<div x-ref="answer" wire:stream="answer" class="reply" :class="hasAnswer && 'stream-caret'"></div>
</div>
</div>
The wire:stream="answer" element receives each streamed update directly in the
browser — no full Livewire round-trip per token. A small Alpine.js component
(chatBox(), also in this file) handles the niceties: it shows the user's
question optimistically the instant they hit send, disables the composer while
streaming, auto-scrolls as the reply grows, and resets on conversation switch.
The result: the user sees a "Using calculator…" pill, then the answer typing
itself out live, then a clean persisted message — all from that one send()
method.
8. Persisting conversations
You didn't write any persistence code in send() — the SDK did it. Three pieces
make that work.
1. The user owns conversations. The User model uses the SDK's
HasConversations trait, which is what makes ->forUser($user) and
->continue(..., as: $user) work:
// app/Models/User.php
use Laravel\Ai\Concerns\HasConversations;
class User extends Authenticatable
{
use HasConversations, HasFactory, Notifiable;
// ...
}
2. The agent remembers. The RemembersConversations trait on ChatAgent
(Section 5) is what actually writes each turn and replays history.
3. The schema. A migration extending Laravel\Ai\Migrations\AiMigration
creates two tables — one for conversations, one for messages:
// database/migrations/..._create_agent_conversations_table.php
return new class extends AiMigration
{
public function up(): void
{
$conversationsTable = config('ai.conversations.tables.conversations', 'agent_conversations');
$messagesTable = config('ai.conversations.tables.messages', 'agent_conversation_messages');
Schema::create($conversationsTable, function (Blueprint $table) {
$table->string('id', 36)->primary();
$table->foreignId('user_id')->nullable();
$table->string('title');
$table->timestamps();
$table->index(['user_id', 'updated_at']);
});
Schema::create($messagesTable, function (Blueprint $table) {
$table->string('id', 36)->primary();
$table->string('conversation_id', 36)->index();
$table->foreignId('user_id')->nullable();
$table->string('agent');
$table->string('role', 25);
$table->text('content');
$table->text('attachments');
$table->text('tool_calls');
$table->text('tool_results');
$table->text('usage');
$table->text('meta');
$table->timestamps();
$table->index(['conversation_id', 'user_id', 'updated_at'], 'conversation_index');
$table->index(['user_id']);
});
}
// ...
};
Each message row stores the role (user / assistant / tool), the
content, and — useful for our UI — the tool_calls the assistant made. That's
how the component can render a "calculator" pill next to a persisted answer: it
reads the saved tool_calls for that message. You query these through the SDK's
Laravel\Ai\Models\Conversation and Laravel\Ai\Models\ConversationMessage
Eloquent models.
9. Testing
Hitting a real model in tests would be slow, costly, and non-deterministic. The
SDK provides ChatAgent::fake() to swap the agent for a scripted response, plus
assertions to verify it was prompted correctly.
// tests/Feature/ChatTest.php
it('streams an answer and persists the conversation', function () {
ChatAgent::fake(['Hello from the agent!']);
$component = Livewire::test('chat.main', ['userId' => $this->user->id])
->call('send', 'Hi there')
->assertSee('Hi there')
->assertSee('Hello from the agent!')
->assertDispatched('conversation-updated');
expect($component->get('conversationId'))->not->toBeNull();
ChatAgent::assertPrompted('Hi there');
expect(ConversationMessage::where('role', 'user')->count())->toBe(1)
->and(ConversationMessage::where('role', 'assistant')->count())->toBe(1);
});
What this verifies, end to end:
-
ChatAgent::fake(['Hello from the agent!'])makes the agent return a canned reply instead of calling Anthropic. - The Livewire flow renders both the user's prompt and the agent's reply, and
dispatches the
conversation-updatedevent. -
ChatAgent::assertPrompted('Hi there')confirms the agent received the exact prompt. - The user and assistant messages were persisted — proving the
RemembersConversationswiring works.
There's a matching assertNotPrompted() for the "ignore blank prompts" case, and
the tools are covered by fast unit/feature tests
(tests/Unit/CalculatorTest.php, tests/Unit/CurrentDateTimeTest.php,
tests/Feature/WikipediaLookupTest.php) that exercise their handle() logic and
error paths directly — no model needed.
Run them with:
php artisan test --compact
10. Extending it: add your own tool
Adding a capability to the agent is three steps:
-
Create the tool.
php artisan make:tool MyToolscaffolds a class inapp/Ai/Tools/. Implementname(),description(),schema(), andhandle()— model thedescription/schematext carefully, since that's how the model decides to call it. Return plain strings, including for errors. -
Register it in
ChatAgent::tools()by addingnew MyToolto the array. -
Optionally mention it in the agent's
instructions()so the model knows when to prefer it, and test it with a unit test plus a faked-agent feature test.
That's the entire loop. The SDK handles the reasoning, the tool calls, the
streaming, and the persistence; you provide a well-described agent and a handful
of safe, honest tools — and the ReAct pattern does the rest.
Source Code
You can find the complete source code for this tutorial in the accompanying GitHub repository:
Reference: files in this walkthrough
-
app/Ai/Agents/ChatAgent.php— the agent -
app/Ai/Tools/CurrentDateTime.php,WikipediaLookup.php,Calculator.php— custom tools -
resources/views/components/chat/⚡main.blade.php— chat UI + streaming -
app/Models/User.php— conversation ownership -
database/migrations/..._create_agent_conversations_table.php— persistence schema -
config/ai.php— provider configuration -
tests/Feature/ChatTest.php,tests/Unit/*— tests
Top comments (0)