# Your first AI agent in Laravel: a streaming FAQ bot

You will build a page with one question box. A visitor asks about an imaginary coworking studio and the answer streams in word by word, from a graph with a single node.

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

```bash
ollama pull lfm3
```

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

> **Checkpoint**
>
> Run `ollama list`.
>
> You should see `lfm3` in the list.

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

```bash
composer require agenticgraph/agenticgraph
php artisan vendor:publish --tag=agenticgraph-config
php artisan vendor:publish --tag=agenticgraph-migrations
```

Add the model, the queue and the tracing settings. `AGENTICGRAPH_PRISM_MODEL` has no default, so the first run fails without it.

```ini
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=database
```

> **Checkpoint**
>
> Run `php artisan agenticgraph:list`.
>
> You should see `No graphs registered`. The package is installed and booted, and you have not added your graph yet.

## Create 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.

```bash
touch database/database.sqlite
php artisan migrate
```

> **Checkpoint**
>
> Run `php artisan migrate:status`.
>
> You should see every migration marked `Ran`, including the ones for `agenticgraph_executions`, `agenticgraph_checkpoints` and `agenticgraph_stream_events`.

## Write 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](https://agenticgraph.dev/docs/nodes) for the full contract.

```php
<?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);
    }
}
```

> **Checkpoint**
>
> Open `app/Agents/FaqBot/AnswerNode.php`.
>
> You should see the facts in `SYSTEM`, and the question passed through `vars(['question' => ...])`.

## 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](https://agenticgraph.dev/docs/graph).

*Diagram: faq-bot: answer, then end (answer → end)*

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

```php
    'graphs' => [
        'faq-bot' => FaqBotGraph::class,
    ],
```

> **Checkpoint**
>
> Run `php artisan agenticgraph:list`.
>
> You should see `faq-bot` in the list.

## 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
<?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
<?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');
```

> **Checkpoint**
>
> Run `php artisan route:list --path=ask`.
>
> You should see `POST 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](https://agenticgraph.dev/docs/streaming).

```bash
npm install @agenticgraph/stream-client
```

The page loads the script with the usual Vite tag:

```blade
@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:

```blade
        <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:

```js
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;
        }
    });
}
```

> **Checkpoint**
>
> Run `npm run build`.
>
> You should see Vite finish without errors and list `resources/js/app.js` among the built files.

## Run it

You need two terminals. The first serves the app:

```bash
php artisan serve
```

The second runs the queue worker, which executes the graph:

```bash
php artisan queue:work --queue=agenticgraph
```

> **Checkpoint**
>
> Open http://localhost:8000 and ask "How much is a flex desk?".
>
> You should see the status change to `thinking`, then the answer appear in pieces, then the status change to `complete`. If nothing arrives, check that the worker is running.

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

```text
answer (PrismNode) -> end
```

A request goes through four hands:

1. The browser posts the question to `/ask`.
2. `AskController` validates it, starts the run with `AgenticGraph::stream()` and returns the stream URL.
3. The queue worker picks up the run and executes `answer`. The model call streams, and each piece is stored as a `text_delta` event.
4. 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_MODEL` is 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
<?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.

> **Checkpoint**
>
> Run `php artisan test`.
>
> You should see every test pass, with no model running.

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

```bash
grep agenticgraph.trace storage/logs/laravel.log
```

> **Checkpoint**
>
> Run the command above after one question.
>
> You should see a trace with a `graph` span, an `answer` node span and an `llm` span. The graph is labelled `FAQ bot`, the node `Answer the question`, and the outcome headline reads `Question answered`.

The headline is a fixed summary. It never contains the visitor's question or the model's answer. See [Tracing](https://agenticgraph.dev/docs/tracing) for what else a trace carries.

### Optional: AgenticPulse or OTLP

To read traces in a UI, send them to [AgenticPulse](https://pulse.agenticgraph.dev) with a test key:

```ini
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:

```ini
AGENTICGRAPH_TRACING_EXPORTER=otlp
AGENTICGRAPH_OTLP_ENDPOINT=http://localhost:4318/v1/traces
```

## Next steps

- Read the docs behind this tutorial: [Graphs](https://agenticgraph.dev/docs/graph), [Nodes](https://agenticgraph.dev/docs/nodes), [Streaming](https://agenticgraph.dev/docs/streaming) and [Tracing](https://agenticgraph.dev/docs/tracing).
- The stream URL has no auth by default. Before you put a bot like this on the internet, set `AGENTICGRAPH_STREAMING_MIDDLEWARE` or sign the URL.
- Next in the series: turn a messy email into typed data with structured output.
