Available for Q3 2026 projects — Laravel, AI agents & automation
Build With Abdallah logo Build With Abdallah Software · AI · Automation
AI Agents 4 min read Jun 01, 2026

How to Build a Background AI Agent in Laravel

A production-style Laravel tutorial for creating a supervised background agent with persistent memory, controlled tools, action logging, and Telegram notifications.

A
Abdallah Mohamed
Senior Full-Stack Engineer
How to Build a Background AI Agent in Laravel

How to Build a Background AI Agent in Laravel

Prerequisites: Laravel 11+, PHP 8.2+, familiarity with Artisan commands and database migrations. Time to complete: ~50 minutes. Companion repository: github.com/buildwithabdallah/laravel-background-agent


What This Tutorial Covers

This is a production-style demonstration of building a background AI agent in Laravel. The agent runs as a supervised daemon process, uses an LLM for reasoning, stores observations in the database, and sends Telegram notifications.

By the end, you will have:

  • An Artisan command that runs a background agent loop
  • A tool registry with allowlisted, controlled actions
  • Persistent memory stored in SQLite/MySQL
  • Action logging and rate limiting
  • Telegram alerts for agent activity
  • Supervisor/systemd deployment configuration
  • A realistic example: monitoring a website or RSS feed, summarizing changes, and alerting on Telegram

How This Differs From Existing Resources

Laravel already has excellent AI tooling:

This tutorial goes further by focusing on background execution, safety, and observability:

Feature Laravel AI SDK / LarAgent Tutorials This Tutorial
Background loop One-shot execution Continuous daemon with Supervisor
Persistent memory In-memory or session Database-backed, survives restarts
Controlled tools Open tool calling Allowlisted + validated + logged
Action logs Basic output Full audit trail per step
Telegram alerts Not covered Native notification channel
Safety guardrails Not emphasized Rate limits, approval gates, no shell
Deployment Manual execution Supervisor + systemd ready

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                      Supervisor/Systemd                     │
│  ┌───────────────────────────────────────────────────────┐  │
│  │              Laravel Artisan Command                  │  │
│  │  ┌─────────────┐    ┌─────────────┐    ┌─────────┐ │  │
│  │  │   Agent     │───▶│   LLM       │◀───│ Memory  │ │  │
│  │  │   Loop      │    │   (OpenAI/  │    │ (DB)    │ │  │
│  │  │             │◀───│   Ollama)   │───▶│         │ │  │
│  │  └─────────────┘    └─────────────┘    └─────────┘ │  │
│  │         │                                          │  │
│  │         ▼                                          │  │
│  │  ┌─────────────┐    ┌─────────────┐              │  │
│  │  │   Tool      │───▶│   Action    │              │  │
│  │  │   Registry  │    │   Logger    │              │  │
│  │  │(allowlisted)│   │   (DB)      │              │  │
│  │  └─────────────┘    └─────────────┘              │  │
│  │         │                                          │  │
│  │         ▼                                          │  │
│  │  ┌─────────────┐                                   │  │
│  │  │  Telegram   │                                   │  │
│  │  │ Notification│                                   │  │
│  │  └─────────────┘                                   │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

Execution flow:

  1. Supervisor keeps the Artisan command running
  2. Agent receives a goal (e.g., "Monitor example.com hourly")
  3. Agent queries LLM for reasoning + tool selection
  4. Only allowlisted tools execute; all calls are logged
  5. Observations stored in database (memory)
  6. Telegram notification sent on completion or anomaly
  7. Loop sleeps, then repeats

Project Setup

Step 1: Create Laravel Project

composer create-project laravel/laravel background-agent-demo
cd background-agent-demo

Step 2: Install Laravel AI SDK

The Laravel AI SDK provides a clean, first-party interface for LLM interactions:

composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"

Add credentials to .env:

# Option A: Cloud LLM
OPENAI_API_KEY=sk-...

# Option B: Local LLM via Ollama (free, runs on your machine)
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=llama3.2:3b

See Laravel AI SDK docs for full configuration options.

Step 3: Install Telegram Notifications

composer require laravel-notification-channels/telegram

Add to .env:

TELEGRAM_BOT_TOKEN=your_bot_token
TELEGRAM_CHAT_ID=your_chat_id

Step 4: Create Database Tables

php artisan make:migration create_agent_runs_table
php artisan make:migration create_agent_logs_table
php artisan make:migration create_agent_memory_table

agent_runs:

Schema::create('agent_runs', function (Blueprint $table) {
    $table->id();
    $table->string('goal');
    $table->text('plan')->nullable();
    $table->integer('steps_taken')->default(0);
    $table->string('status')->default('running'); // running, completed, failed, paused
    $table->json('context')->nullable();
    $table->text('summary')->nullable();
    $table->timestamps();
});

agent_logs:

Schema::create('agent_logs', function (Blueprint $table) {
    $table->id();
    $table->foreignId('agent_run_id')->constrained()->onDelete('cascade');
    $table->string('step');
    $table->string('tool')->nullable();
    $table->text('input')->nullable();
    $table->text('output')->nullable();
    $table->string('level')->default('info'); // info, warning, error
    $table->timestamps();
});

agent_memory:

Schema::create('agent_memory', function (Blueprint $table) {
    $table->id();
    $table->string('category')->default('general');
    $table->string('key')->index();
    $table->text('value');
    $table->timestamps();
});
php artisan migrate

The Agent Core: Safe Reasoning and Tool Execution

Why Safety First

The agent receives instructions from an LLM. Without guardrails, a malformed response could execute unintended actions. This implementation uses:

  • Allowlist-only tools — The agent cannot execute arbitrary code
  • Structured JSON parsing — Prevents prompt injection from executing commands
  • Rate limiting — Prevents runaway LLM calls
  • Action logging — Every step is auditable
  • No shell execution — Tools are PHP classes, not system commands

Agent Class

php artisan make:class Services/Agent/BackgroundAgent
<?php

namespace App\Services\Agent;

use Laravel\Ai\Ai;
use App\Models\AgentRun;
use App\Models\AgentLog;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;

class BackgroundAgent
{
    private ToolRegistry $tools;
    private AgentRun $run;
    private array $context = [];
    private int $maxSteps = 8;
    private int $currentStep = 0;

    public function __construct(ToolRegistry $tools)
    {
        $this->tools = $tools;
    }

    public function execute(string $goal): AgentRun
    {
        $this->run = AgentRun::create([
            'goal' => $goal,
            'status' => 'running',
        ]);

        $this->log('info', 'Agent started', ['goal' => $goal]);

        $this->context = [
            ['role' => 'system', 'content' => $this->systemPrompt()],
            ['role' => 'user', 'content' => "Task: {$goal}"],
        ];

        while ($this->currentStep < $this->maxSteps) {
            if (!$this->checkRateLimit()) {
                $this->log('warning', 'Rate limit exceeded, pausing');
                break;
            }

            $decision = $this->think();

            if (isset($decision['complete'])) {
                $this->complete($decision['complete']);
                return $this->run;
            }

            if (isset($decision['tool'])) {
                $result = $this->act($decision['tool'], $decision['input'] ?? '');
                $this->observe($result);
                $this->currentStep++;
                $this->run->increment('steps_taken');
            }

            if (isset($decision['error'])) {
                $this->log('error', 'LLM returned invalid decision', ['error' => $decision['error']]);
                break;
            }
        }

        $this->fail('Maximum steps reached or rate limit exceeded');
        return $this->run;
    }

    private function systemPrompt(): string
    {
        $toolList = $this->tools->descriptions();

        return <<<PROMPT
You are a supervised background agent running in a Laravel application.
You help users by monitoring sources, summarizing information, and sending notifications.

You have access to these tools:
{$toolList}

Rules:
1. You may ONLY use the tools listed above.
2. You CANNOT execute shell commands, modify files directly, or make financial decisions.
3. You CAN monitor websites, read RSS feeds, summarize text, store observations, and send notifications.
4. You CAN draft summaries and recommendations for human review.
5. You CANNOT take destructive actions without explicit human approval.

Response format (JSON only):
- To use a tool: {"tool": "tool_name", "input": "tool input"}
- When finished: {"complete": "summary of what was accomplished"}
- If stuck: {"complete": "Unable to complete: [reason]"}

Be concise. Prefer facts over speculation.
PROMPT;
    }

    private function think(): array
    {
        try {
            $response = Ai::chat()
                ->withModel(config('ai.model', 'gpt-4o-mini'))
                ->withMessages($this->context)
                ->send();

            $content = $response->text();
            $this->log('info', 'LLM response', ['content' => $content]);

            return $this->parseDecision($content);

        } catch (\Exception $e) {
            $this->log('error', 'LLM call failed', ['error' => $e->getMessage()]);
            return ['error' => $e->getMessage()];
        }
    }

    private function parseDecision(string $content): array
    {
        // Extract JSON block if wrapped in markdown
        if (preg_match('/```json\s*(.*?)\s*```/s', $content, $matches)) {
            $content = $matches[1];
        }

        $decoded = json_decode($content, true);

        if (!is_array($decoded)) {
            // Fallback: treat as complete if no JSON found
            return ['complete' => $content];
        }

        if (isset($decoded['tool']) && isset($decoded['input'])) {
            // Validate tool exists
            if (!$this->tools->exists($decoded['tool'])) {
                return ['complete' => "Tool '{$decoded['tool']}' not in allowlist. Stopping."];
            }
            return $decoded;
        }

        if (isset($decoded['complete'])) {
            return $decoded;
        }

        return ['error' => 'Unrecognized response format'];
    }

    private function act(string $toolName, string $input): string
    {
        $this->log('info', 'Executing tool', ['tool' => $toolName, 'input' => $input]);

        $tool = $this->tools->get($toolName);

        if (!$tool) {
            return "Error: Tool '{$toolName}' not found in registry.";
        }

        try {
            $result = $tool->execute($input);
            $this->log('info', 'Tool completed', ['tool' => $toolName, 'output' => substr($result, 0, 500)]);
            return $result;
        } catch (\Exception $e) {
            $this->log('error', 'Tool failed', ['tool' => $toolName, 'error' => $e->getMessage()]);
            return "Error: {$e->getMessage()}";
        }
    }

    private function observe(string $result): void
    {
        $this->context[] = [
            'role' => 'assistant',
            'content' => json_encode(['observation' => $result]),
        ];
    }

    private function checkRateLimit(): bool
    {
        return RateLimiter::attempt(
            'agent-llm-calls',
            10, // attempts
            fn() => true,
            60  // per minute
        );
    }

    private function complete(string $summary): void
    {
        $this->run->update([
            'status' => 'completed',
            'summary' => $summary,
            'context' => $this->context,
        ]);

        $this->log('info', 'Agent completed', ['summary' => $summary]);
    }

    private function fail(string $reason): void
    {
        $this->run->update([
            'status' => 'failed',
            'summary' => $reason,
        ]);

        $this->log('error', 'Agent failed', ['reason' => $reason]);
    }

    private function log(string $level, string $message, array $data = []): void
    {
        AgentLog::create([
            'agent_run_id' => $this->run->id,
            'step' => $this->currentStep,
            'level' => $level,
            'output' => $message . ' ' . json_encode($data),
        ]);

        Log::channel('agent')->{$level}($message, $data);
    }
}

Tool Registry: Controlled Actions Only

Tool Interface

php artisan make:interface Services/Agent/Tools/AgentTool
<?php

namespace App\Services\Agent\Tools;

interface AgentTool
{
    public function name(): string;
    public function description(): string;
    public function execute(string $input): string;
}

Tool Registry

php artisan make:class Services/Agent/ToolRegistry
<?php

namespace App\Services\Agent;

use App\Services\Agent\Tools\AgentTool;

class ToolRegistry
{
    private array $tools = [];

    public function register(AgentTool $tool): void
    {
        $this->tools[$tool->name()] = $tool;
    }

    public function get(string $name): ?AgentTool
    {
        return $this->tools[$name] ?? null;
    }

    public function exists(string $name): bool
    {
        return isset($this->tools[$name]);
    }

    public function descriptions(): string
    {
        $lines = [];
        foreach ($this->tools as $tool) {
            $lines[] = "- {$tool->name()}: {$tool->description()}";
        }
        return implode("\n", $lines);
    }
}

Safe Tools

Web Fetch Tool (Read-Only):

php artisan make:class Services/Agent/Tools/WebFetchTool
<?php

namespace App\Services\Agent\Tools;

use Illuminate\Support\Facades\Http;

class WebFetchTool implements AgentTool
{
    public function name(): string
    {
        return 'fetch_url';
    }

    public function description(): string
    {
        return 'Fetch and extract text from a URL. Input: URL string. Output: Plain text content (max 3000 chars).';
    }

    public function execute(string $input): string
    {
        // Validate URL
        if (!filter_var($input, FILTER_VALIDATE_URL)) {
            return 'Error: Invalid URL format.';
        }

        // Block private networks
        $host = parse_url($input, PHP_URL_HOST);
        $ip = gethostbyname($host);
        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
            return 'Error: Private/reserved URLs not allowed.';
        }

        try {
            $response = Http::timeout(15)->withOptions([
                'verify' => true, // Require valid SSL
            ])->get($input);

            if (!$response->successful()) {
                return "Error: HTTP {$response->status()}";
            }

            $text = strip_tags($response->body());
            $text = preg_replace('/\s+/', ' ', $text);
            $text = trim($text);

            return substr($text, 0, 3000) . (strlen($text) > 3000 ? '... [truncated]' : '');

        } catch (\Exception $e) {
            return "Error: {$e->getMessage()}";
        }
    }
}

RSS Feed Reader:

php artisan make:class Services/Agent/Tools/RssReaderTool
<?php

namespace App\Services\Agent\Tools;

use Illuminate\Support\Facades\Http;

class RssReaderTool implements AgentTool
{
    public function name(): string
    {
        return 'read_rss';
    }

    public function description(): string
    {
        return 'Read an RSS/Atom feed and return recent items. Input: RSS feed URL. Output: JSON array of items.';
    }

    public function execute(string $input): string
    {
        if (!filter_var($input, FILTER_VALIDATE_URL)) {
            return 'Error: Invalid URL.';
        }

        try {
            $response = Http::timeout(15)->get($input);
            $xml = simplexml_load_string($response->body());

            $items = [];
            $count = 0;
            foreach ($xml->channel->item ?? [] as $item) {
                if ($count++ >= 10) break;
                $items[] = [
                    'title' => (string) $item->title,
                    'link' => (string) $item->link,
                    'pubDate' => (string) $item->pubDate,
                ];
            }

            return json_encode($items, JSON_PRETTY_PRINT);

        } catch (\Exception $e) {
            return "Error: {$e->getMessage()}";
        }
    }
}

Text Summarizer (Local):

php artisan make:class Services/Agent/Tools/SummarizeTool
<?php

namespace App\Services\Agent\Tools;

class SummarizeTool implements AgentTool
{
    public function name(): string
    {
        return 'summarize';
    }

    public function description(): string
    {
        return 'Create a brief summary of provided text. Input: Text to summarize. Output: 2-3 sentence summary.';
    }

    public function execute(string $input): string
    {
        $sentences = preg_split('/(?<=[.!?])\s+/', $input, -1, PREG_SPLIT_NO_EMPTY);
        
        if (count($sentences) <= 3) {
            return $input;
        }

        // Simple extraction: first sentence + most significant sentence
        $first = $sentences[0];
        $longest = array_reduce($sentences, fn($a, $b) => strlen($a) > strlen($b) ? $a : $b, '');

        return "Summary: {$first} Key point: {$longest}";
    }
}

Store Observation:

php artisan make:class Services/Agent/Tools/StoreObservationTool
<?php

namespace App\Services\Agent\Tools;

use App\Models\AgentMemory;

class StoreObservationTool implements AgentTool
{
    public function name(): string
    {
        return 'store_observation';
    }

    public function description(): string
    {
        return 'Store an observation in persistent memory. Input: JSON with {"key": "unique_key", "value": "text", "category": "optional"}.';
    }

    public function execute(string $input): string
    {
        $data = json_decode($input, true);

        if (!isset($data['key']) || !isset($data['value'])) {
            return 'Error: Input must include "key" and "value".';
        }

        AgentMemory::updateOrCreate(
            ['key' => $data['key']],
            [
                'value' => $data['value'],
                'category' => $data['category'] ?? 'general',
            ]
        );

        return "Stored observation with key: {$data['key']}";
    }
}

Memory: Database-Backed Persistence

The agent stores observations in the database, allowing it to:

  • Compare current vs. previous states
  • Resume after restarts
  • Build knowledge over time
php artisan make:class Services/Agent/Memory
<?php

namespace App\Services\Agent;

use App\Models\AgentMemory;

class Memory
{
    public function store(string $key, string $value, string $category = 'general'): void
    {
        AgentMemory::updateOrCreate(
            ['key' => $key],
            ['value' => $value, 'category' => $category]
        );
    }

    public function retrieve(string $key): ?string
    {
        return AgentMemory::where('key', $key)->first()?->value;
    }

    public function retrieveByCategory(string $category): array
    {
        return AgentMemory::where('category', $category)
            ->pluck('value', 'key')
            ->toArray();
    }

    public function hasChanged(string $key, string $currentValue): bool
    {
        $previous = $this->retrieve($key);
        return $previous !== $currentValue;
    }
}

The Loop: Background Execution

php artisan make:command AgentRun --command="agent:run"
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Services\Agent\BackgroundAgent;
use App\Services\Agent\ToolRegistry;
use App\Services\Agent\Memory;
use App\Services\Agent\Tools\WebFetchTool;
use App\Services\Agent\Tools\RssReaderTool;
use App\Services\Agent\Tools\SummarizeTool;
use App\Services\Agent\Tools\StoreObservationTool;
use App\Notifications\AgentActivity;
use Illuminate\Support\Facades\Notification;

class AgentRun extends Command
{
    protected $signature = 'agent:run
                            {goal : The task for the agent}
                            {--continuous : Run in a loop with sleep interval}
                            {--interval=3600 : Seconds between runs in continuous mode}';

    protected $description = 'Run the background AI agent';

    public function handle()
    {
        $goal = $this->argument('goal');
        $continuous = $this->option('continuous');
        $interval = (int) $this->option('interval');

        // Build tool registry
        $registry = new ToolRegistry();
        $registry->register(new WebFetchTool());
        $registry->register(new RssReaderTool());
        $registry->register(new SummarizeTool());
        $registry->register(new StoreObservationTool());

        $agent = new BackgroundAgent($registry);
        $memory = new Memory();

        $this->info("Agent starting: {$goal}");

        do {
            $run = $agent->execute($goal);

            $this->info("Run {$run->id}: {$run->status} in {$run->steps_taken} steps");

            // Send Telegram notification
            Notification::route('telegram', config('services.telegram.chat_id'))
                ->notify(new AgentActivity($run));

            if ($continuous) {
                $this->info("Sleeping for {$interval} seconds...");
                sleep($interval);
            }

        } while ($continuous);

        return 0;
    }
}

Telegram Notifications

php artisan make:notification AgentActivity
<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use NotificationChannels\Telegram\TelegramMessage;
use App\Models\AgentRun;

class AgentActivity extends Notification
{
    use Queueable;

    private AgentRun $run;

    public function __construct(AgentRun $run)
    {
        $this->run = $run;
    }

    public function via($notifiable): array
    {
        return ['telegram'];
    }

    public function toTelegram($notifiable): TelegramMessage
    {
        $statusEmoji = match($this->run->status) {
            'completed' => '✅',
            'failed' => '❌',
            default => '🔄',
        };

        $message = <<>MSG
{$statusEmoji} **Agent Activity Report**

**Task:** {$this->run->goal}
**Status:** {$this->run->status}
**Steps:** {$this->run->steps_taken}
**Time:** {$this->run->created_at->format('Y-m-d H:i:s')}

**Summary:**
{$this->run->summary}
MSG;

        return TelegramMessage::create()
            ->to(config('services.telegram.chat_id'))
            ->content($message);
    }
}

Running 24/7 with Supervisor

Install Supervisor

# Ubuntu/Debian
sudo apt-get install supervisor

# macOS
brew install supervisor

Configuration File

Create /etc/supervisor/conf.d/laravel-agent.conf:

[program:laravel-agent]
process_name=%(program_name)s_%(process_num)02d
command=php /path/to/artisan agent:run "Monitor https://example.com/feed and notify on changes" --continuous --interval=3600
directory=/path/to/project
autostart=true
autorestart=true
user=www-data
numprocs=1
redirect_stderr=true
stdout_logfile=/var/log/laravel-agent.log
stopwaitsecs=3600

Start and Monitor

sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-agent

# View logs
tail -f /var/log/laravel-agent.log

# Check status
sudo supervisorctl status

Realistic Example: Website Monitor

Use Case

Monitor a website or RSS feed hourly. Store the content hash. If content changes, summarize the change and send a Telegram notification.

Implementation

php artisan make:command AgentMonitorWebsite --command="agent:monitor"
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Services\Agent\BackgroundAgent;
use App\Services\Agent\ToolRegistry;
use App\Services\Agent\Memory;
use App\Services\Agent\Tools\WebFetchTool;
use App\Services\Agent\Tools\RssReaderTool;
use App\Services\Agent\Tools\SummarizeTool;
use App\Services\Agent\Tools\StoreObservationTool;
use App\Notifications\AgentActivity;
use Illuminate\Support\Facades\Notification;

class AgentMonitorWebsite extends Command
{
    protected $signature = 'agent:monitor
                            {url : Website or RSS URL to monitor}
                            {--interval=3600 : Seconds between checks}
                            {--rss : Treat URL as RSS feed}';

    protected $description = 'Monitor a website or RSS feed for changes';

    public function handle()
    {
        $url = $this->argument('url');
        $interval = (int) $this->option('interval');
        $isRss = $this->option('rss');

        $registry = new ToolRegistry();
        $registry->register(new WebFetchTool());
        $registry->register(new RssReaderTool());
        $registry->register(new SummarizeTool());
        $registry->register(new StoreObservationTool());

        $memory = new Memory();
        $agent = new BackgroundAgent($registry);

        $this->info("Starting monitor for: {$url}");
        $this->info("Interval: {$interval} seconds");

        while (true) {
            $goal = $isRss
                ? "Read RSS feed at {$url}, compare with previous observations, summarize any new items"
                : "Fetch content from {$url}, compare with previous hash, summarize changes if different";

            $run = $agent->execute($goal);

            // Only notify if something changed or if there was an error
            if ($run->status === 'completed' && str_contains($run->summary, 'changed')) {
                Notification::route('telegram', config('services.telegram.chat_id'))
                    ->notify(new AgentActivity($run));
                $this->info('Change detected — notification sent.');
            } elseif ($run->status === 'failed') {
                Notification::route('telegram', config('services.telegram.chat_id'))
                    ->notify(new AgentActivity($run));
                $this->warn('Monitor failed — notification sent.');
            } else {
                $this->info('No changes detected.');
            }

            $this->info("Sleeping for {$interval} seconds...");
            sleep($interval);
        }
    }
}

Run It

# One-time check
php artisan agent:monitor https://example.com/feed --rss

# Continuous background monitor
php artisan agent:monitor https://example.com/feed --rss --interval=1800

Safety & Production Considerations

Allowlisted Tools Only

The agent can only execute tools explicitly registered in ToolRegistry. There is no generic "execute code" or "run shell command" tool.

Allowed Not Allowed
Fetch URL (read-only) Shell execution
Read RSS feed File modification
Store observation Database writes (except agent_memory)
Summarize text API calls to external services
Send Telegram notification Financial transactions

Rate Limiting

LLM calls are rate-limited using Laravel's built-in RateLimiter:

RateLimiter::attempt('agent-llm-calls', 10, fn() => true, 60);

This prevents runaway agents from consuming API credits.

Action Logging

Every tool execution is logged to the agent_logs table:

# View recent activity
php artisan tinker
>>> App\Models\AgentLog::latest()->take(10)->get();

Logs Channel

Add to config/logging.php:

'agent' => [
    'driver' => 'daily',
    'path' => storage_path('logs/agent.log'),
    'level' => 'debug',
    'days' => 14,
],

Human Approval for Destructive Actions

The agent is designed as a monitor and reporter, not an actor. It:

  • ✅ Reads and summarizes
  • ✅ Stores observations
  • ✅ Sends notifications
  • ❌ Does not modify data
  • ❌ Does not execute commands
  • ❌ Does not make financial decisions

For actions that modify state, implement an approval queue:

// In a tool that requires approval
class DataModificationTool implements AgentTool
{
    public function execute(string $input): string
    {
        // Queue for human approval instead of executing
        ApprovalQueue::create([
            'tool' => $this->name(),
            'input' => $input,
            'status' => 'pending',
        ]);

        return 'Action queued for human approval.';
    }
}

Environment Security

  • Store API keys in .env, never commit them
  • Use Laravel's encrypted environment files for production
  • Restrict Telegram bot to known chat IDs
  • Validate all URLs before fetching (no private networks)

Testing and Debugging

Run Single Task

php artisan agent:run "Fetch and summarize https://example.com"

View Agent State

php artisan tinker
>>> App\Models\AgentRun::latest()->first();
>>> App\Models\AgentLog::where('agent_run_id', 1)->get();
>>> App\Models\AgentMemory::all();

Test Individual Tools

php artisan tinker
>>> (new App\Services\Agent\Tools\WebFetchTool)->execute('https://example.com');

Check Logs

tail -f storage/logs/agent.log

Extending the Agent

Adding New Tools

  1. Implement AgentTool interface
  2. Register in the command's ToolRegistry
  3. The LLM automatically discovers it via the system prompt

Example: Database Query Tool (Read-Only)

class DatabaseQueryTool implements AgentTool
{
    public function name(): string { return 'db_query'; }
    public function description(): string {
        return 'Execute a read-only SELECT query. Input: SQL query string.';
    }
    public function execute(string $input): string
    {
        if (!preg_match('/^\s*SELECT/i', $input)) {
            return 'Error: Only SELECT queries permitted.';
        }
        return json_encode(\DB::select($input));
    }
}

Multi-Agent Setup

Create specialized agents for different domains:

class ResearchAgent extends BackgroundAgent {
    protected function systemPrompt(): string {
        return 'You are a research agent. Gather information and summarize findings.';
    }
}

class MonitorAgent extends BackgroundAgent {
    protected function systemPrompt(): string {
        return 'You are a monitoring agent. Watch for changes and alert on anomalies.';
    }
}

Queue-Based Goals

Instead of hardcoding goals, read from a queue:

// In the command loop
$goal = Redis::lpop('agent:goals');
if ($goal) {
    $agent->execute($goal);
}

Comparison With Existing Packages

Package Best For This Tutorial Adds
Laravel AI SDK First-party AI features Background execution, safety layer
Prism LLM abstraction Tool control, action logging
LarAgent Local LLM agents Persistent memory, deployment
NeuronAI Agent monitoring Notification channels

This tutorial is not a replacement for these packages — it is a production blueprint that demonstrates how to build a supervised, observable, safe background agent using Laravel primitives.


Conclusion

This tutorial demonstrated how to build a background AI agent in Laravel that:

  • Runs continuously as a supervised daemon
  • Uses an LLM for reasoning with structured JSON responses
  • Executes only allowlisted, safe tools
  • Stores observations in persistent database memory
  • Sends Telegram notifications on activity
  • Logs every action for auditability
  • Includes rate limiting and safety guardrails

The example use case — monitoring a website or RSS feed, storing observations, summarizing changes, and alerting via Telegram — is a realistic starting point that can be extended to many domains.


Resources and References


Questions or improvements? Drop a comment or reach out on Twitter/X or LinkedIn.

Built with Laravel, local LLMs, and careful engineering. 🛡️