Before you start
You need PHP 8.3 or newer with Composer, Node with npm, and SQLite. The model runs on your machine through Ollama, so you need no API key and spend nothing. Pull the model once:
ollama pull lfm3Ollama must be running on http://localhost:11434. That is the server root, with no /v1 on the end.
Any provider works. Set AGENTICGRAPH_PRISM_MODEL to provider:model and set that provider's key, and nothing else in this tutorial changes.
Create the app and install the package
Start from a fresh Laravel 13 app, or clone the repository from the project card. Install the core package, then publish its config and migrations.
composer require agenticgraph/agenticgraph
php artisan vendor:publish --tag=agenticgraph-config
php artisan vendor:publish --tag=agenticgraph-migrationsAdd the model, the queue and the tracing settings. AGENTICGRAPH_PRISM_MODEL has no default, so the first run fails without it.
AGENTICGRAPH_PRISM_MODEL=ollama:lfm3
OLLAMA_URL=http://localhost:11434
AGENTICGRAPH_STORAGE=database
AGENTICGRAPH_CHECKPOINT_ENABLED=true
AGENTICGRAPH_QUEUE_CONNECTION=database
AGENTICGRAPH_QUEUE_NAME=agenticgraph
AGENTICGRAPH_TRACING=true
AGENTICGRAPH_TRACING_EXPORTER=log
AGENTICGRAPH_SERVICE_NAME=agenticgraph-faq-bot
STREAM_STORE_DRIVER=databaseCreate the database
Streamed events and run records live in the database. SQLite stores everything in one file, and the file has to exist before you migrate.
touch database/database.sqlite
php artisan migrateWrite the answer node
A node is a class with one execute method that reads state and returns state. A PrismNode also gives you $this->llm() to call the model. The product facts go in the system prompt, and the visitor's question goes in through vars(). It never goes into the system text or the prompt template, so the question stays data and cannot act as an instruction. See Nodes for the full contract.
<?php
namespace App\Agents\FaqBot;
use Shahzad\AgenticGraph\Contracts\StateContract;
use Shahzad\AgenticGraph\Graph\Annotate;
use Shahzad\AgenticGraph\Nodes\PrismNode;
#[Annotate('Answer the question', description: 'Answered from the Lantern facts')]
final class AnswerNode extends PrismNode
{
protected ?int $maxTokens = 1024;
protected ?float $temperature = 0.2;
private const SYSTEM = <<<'PROMPT'
You answer visitor questions about Lantern, a coworking studio.
Facts:
- Day pass: 12 USD per day.
- Flex desk: 149 USD per month, any free desk, cancel with 14 days notice.
- Fixed desk: 249 USD per month, the same desk every day, cancel with 30 days notice.
- Opening hours: Monday to Friday 07:00 to 20:00, Saturday 09:00 to 17:00, closed Sunday.
- Refunds: a day pass is refundable until it is used. Monthly plans are refunded pro rata within the first 7 days. After 7 days no refund is given.
- Contact: hello@lantern.example or +1 555 0100.
Rules:
- Answer only from the facts above.
- Never invent a price, a policy, an hour or a service.
- If the facts do not cover the question, say you do not know and point to hello@lantern.example.
- Keep answers short: two or three sentences.
PROMPT;
public function execute(StateContract $state): StateContract
{
$answer = $this->llm()
->system(self::SYSTEM)
->prompt("Answer this visitor question:\n{question}")
->vars(['question' => (string) $state->get('question', '')])
->text();
return $state->set('answer', $answer);
}
}Build the graph
The graph has one node, and that node is both the entry point and the exit point. label(), description() and outcome() do not change how it runs. They make the trace readable later. See Graphs.
<?php
namespace App\Agents\FaqBot;
use Shahzad\AgenticGraph\Graph\Graph;
use Shahzad\AgenticGraph\Graph\GraphBuilder;
final class FaqBotGraph
{
public static function build(): Graph
{
return GraphBuilder::create('faq-bot')
->label('FAQ bot')
->description('Answers questions about Lantern from its product facts')
->addNode('answer', AnswerNode::class)
->setEntryPoint('answer')
->addExitPoint('answer')
->outcome('Question answered')
->build();
}
}Register it under a name in config/agenticgraph.php:
'graphs' => [
'faq-bot' => FaqBotGraph::class,
],Start a run from a route
AgenticGraph::stream() starts an async run and returns a handle at once. The web request only starts it. The queue worker executes the graph, and the browser reads the events from the stream store. The controller returns the stream URL, with the execution id added so a client can read the final state if it ever has to reconnect from a cursor the store no longer has.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Shahzad\AgenticGraph\Facades\AgenticGraph;
use Shahzad\AgenticGraph\State\State;
final class AskController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$validated = $request->validate([
'question' => ['required', 'string', 'max:500'],
]);
$handle = AgenticGraph::stream('faq-bot', new State([
'question' => $validated['question'],
]));
$streamUrl = $handle->stream()->streamUrl;
if ($streamUrl !== null) {
$streamUrl .= (str_contains($streamUrl, '?') ? '&' : '?')
.'execution_id='.urlencode($handle->id());
}
return response()->json([
'execution_id' => $handle->id(),
'stream_url' => $streamUrl,
]);
}
}<?php
use App\Http\Controllers\AskController;
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => view('chat'))->name('chat');
Route::post('/ask', AskController::class)->name('ask');Show the stream in the browser
Install the stream client from npm and import it through Vite. Do not copy a script into public/. The npm package through Vite is the supported path. See Streaming.
npm install @agenticgraph/stream-clientThe page loads the script with the usual Vite tag:
@vite(['resources/css/app.css', 'resources/js/app.js'])It also needs the form, with the id ask-form, and an answer area that the script fills in:
<section class="min-h-40 space-y-3 rounded-2xl border border-stone-800 bg-stone-900/40 p-5">
<div class="flex items-center justify-between">
<h2 class="text-xs font-medium uppercase tracking-[0.2em] text-stone-500">Answer</h2>
<span id="status" class="text-xs text-stone-500">idle</span>
</div>
<p id="answer" aria-live="polite" class="whitespace-pre-wrap text-base leading-7 text-stone-100"></p>
<p id="notice" role="alert" class="text-sm text-rose-400"></p>
</section>The script posts the form, connects to the stream URL it gets back and appends each text_delta event to the answer:
import { AgenticStream, SSETransport } from '@agenticgraph/stream-client';
const form = document.getElementById('ask-form');
if (form) {
const answer = document.getElementById('answer');
const status = document.getElementById('status');
const notice = document.getElementById('notice');
const submit = document.getElementById('ask-submit');
form.addEventListener('submit', async (event) => {
event.preventDefault();
answer.textContent = '';
notice.textContent = '';
status.textContent = 'thinking';
submit.disabled = true;
try {
const response = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { Accept: 'application/json' },
});
if (!response.ok) {
notice.textContent = 'Enter a question of up to 500 characters.';
status.textContent = 'idle';
submit.disabled = false;
return;
}
const start = await response.json();
const stream = await AgenticStream.connect({
transport: new SSETransport({ url: start.stream_url }),
});
stream.on('text_delta', (event) => {
answer.textContent += event.payload.delta;
});
stream.onStatusChange((next) => {
status.textContent = next;
if (next === 'complete' || next === 'error') {
submit.disabled = false;
}
if (next === 'error') {
notice.textContent = stream.error?.message ?? 'The answer could not be completed.';
}
});
} catch (error) {
notice.textContent = 'The answer could not be started. Try again.';
status.textContent = 'idle';
submit.disabled = false;
}
});
}Run it
You need two terminals. The first serves the app:
php artisan serveThe second runs the queue worker, which executes the graph:
php artisan queue:work --queue=agenticgraphNow ask "Do you have a parking space?". The facts do not cover it, so the answer should be a polite "I do not know" with the contact address, not an invented policy.
How it works
The whole agent is one node:
answer (PrismNode) -> endA request goes through four hands:
- The browser posts the question to
/ask. AskControllervalidates it, starts the run withAgenticGraph::stream()and returns the stream URL.- The queue worker picks up the run and executes
answer. The model call streams, and each piece is stored as atext_deltaevent. - The stream client reads those events from the stream URL and appends them to the page.
Three decisions are worth keeping:
- Facts in the system prompt, question in
vars(). The model treats the system text as instructions and the variable as data. - The model is configuration, not code.
AGENTICGRAPH_PRISM_MODELis the only place that names a provider. - A queue worker, not the web request, runs the graph. The request returns in milliseconds and the model call does not tie up a web process.
Test it
The tests use Llm::fake(), so they need no model, no API key and no Ollama. The first checks that the question reaches the model as a variable. The second runs the whole graph and checks the answer on state.
<?php
use App\Agents\FaqBot\AnswerNode;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Shahzad\AgenticGraph\Execution\ExecutionStatus;
use Shahzad\AgenticGraph\Facades\AgenticGraph;
use Shahzad\AgenticGraph\Jobs\ExecuteGraphJob;
use Shahzad\AgenticGraph\State\State;
use Shahzad\AgenticGraph\Testing\Llm;
uses(RefreshDatabase::class);
it('passes the question to the model as a variable', function () {
$llm = Llm::fake(['A flex desk is 149 USD per month.']);
$result = (new AnswerNode)->execute(new State([
'question' => 'How much is a flex desk?',
]));
expect($result->get('answer'))->toBe('A flex desk is 149 USD per month.');
$llm->assertPrompted(fn (array $call) => $call['vars']['question'] === 'How much is a flex desk?');
$llm->assertSentCount(1);
});
it('completes the graph with the answer on state', function () {
Llm::fake(['Open Monday to Friday, 07:00 to 20:00.']);
$handle = AgenticGraph::run('faq-bot', new State([
'question' => 'When do you open?',
]));
expect($handle->status())->toBe(ExecutionStatus::COMPLETED)
->and($handle->getFinalState()->get('answer'))->toBe('Open Monday to Friday, 07:00 to 20:00.');
});The rest of the file checks that a valid question starts a stream and dispatches ExecuteGraphJob, that a missing question gets a 422, and that the page renders the form.
See it in a trace
Tracing is already on in your .env, with the log exporter. Ask one question, then read the trace from the log:
grep agenticgraph.trace storage/logs/laravel.logThe headline is a fixed summary. It never contains the visitor's question or the model's answer. See Tracing for what else a trace carries.
Optional: AgenticPulse or OTLP
To read traces in a UI, send them to AgenticPulse with a test key:
AGENTICPULSE_API_KEY=ap_test_...With a key set, the default auto exporter picks Pulse. To use any OTLP/HTTP collector instead, such as Jaeger or Grafana Tempo:
AGENTICGRAPH_TRACING_EXPORTER=otlp
AGENTICGRAPH_OTLP_ENDPOINT=http://localhost:4318/v1/traces