---
title: Agent plugin
description: A ReAct-style tool-using agent. Extend Tool to give it any PHP capability you can write.
---

The Agent plugin lets you give an LLM **the ability to act** — to call SQL queries, hit HTTP endpoints, read files, or do anything else you can express as a PHP method. It runs a ReAct-style loop: think → tool-call → observe → repeat → answer.

## Five lines, one agent

```php
use Nibiru\Module\Ai\Ai;
use Nibiru\Module\Ai\Plugin\Tools\PdoQuery;

$ai = new Ai();
echo $ai->agent()
        ->withTools([new PdoQuery()])
        ->run('How many active users do we have?');
// → "We have 1,247 active users." (after the agent ran SELECT count(*)…)
```

## How it works

```
user task
    ↓
LLM gets system prompt with tool definitions
    ↓
LLM emits ```tool {"tool":"pdo_query","args":{"sql":"SELECT…"}}```
    ↓
Agent runs the tool, captures result
    ↓
LLM gets observation, decides: more tools or final answer?
    ↓
"FINAL: 1,247 active users."
```

The protocol uses a **fenced-JSON sentinel** — `\`\`\`tool {...}\`\`\`` — that any model can produce. No native tool-calling API required, so it works on every Ollama model out of the box. (Models that support native tool-calling can be plugged in via a subclass that overrides `parseToolCall()`.)

## Built-in tools

Nibiru ships three:

| Tool | What it does |
|---|---|
| `Tools\PdoQuery` | Single read-only `SELECT` against the app DB. Blocks INSERT/UPDATE/DELETE/DROP/TRUNCATE/ALTER. Returns up to 50 rows as JSON. |
| `Tools\HttpGet` | GET an HTTP/HTTPS URL with optional headers. Returns body, truncated to 8 KB. |
| `Tools\FileRead` | Read a project file by relative path. Blocks `..` traversal. Returns up to 8 KB. |

```php
use Nibiru\Module\Ai\Plugin\Tools;

$agent = $ai->agent()->withTools([
    new Tools\PdoQuery(),
    new Tools\HttpGet(),
    new Tools\FileRead(),
]);

// Multi-step task
echo $agent->run(
    'Read application/controller/loginController.php and tell me '
  . 'whether it implements rate limiting.'
);
```

The agent will call `file_read` with the path, observe the source, and answer based on what it actually saw — not on what it imagines.

## Writing a custom tool

Extend `Tool`:

```php
namespace App\AiTools;

use Nibiru\Module\Ai\Plugin\Tool;

class StripeRefund extends Tool
{
    public function name(): string { return 'stripe_refund'; }

    public function description(): string {
        return 'Issue a Stripe refund for a charge ID.';
    }

    public function schema(): array {
        return [
            'charge_id' => [
                'type' => 'string',
                'description' => 'A Stripe charge ID, e.g. ch_3K…',
                'required' => true,
            ],
            'amount_cents' => [
                'type' => 'integer',
                'description' => 'Amount to refund in cents. Omit for full refund.',
                'required' => false,
            ],
        ];
    }

    public function execute(array $args): mixed {
        $stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY'));
        $refund = $stripe->refunds->create(array_filter([
            'charge' => $args['charge_id'],
            'amount' => $args['amount_cents'] ?? null,
        ]));
        return json_encode([
            'refund_id' => $refund->id,
            'status'    => $refund->status,
            'amount'    => $refund->amount,
        ]);
    }
}
```

Then plug it in:

```php
$ai->agent()
   ->withTools([new \App\AiTools\StripeRefund(), new Tools\PdoQuery()])
   ->run('Refund order #4421 — they were charged twice.');
```

The agent will use `pdo_query` to find the charge, then call `stripe_refund` with that charge ID.

## Looking at the trace

```php
$agent = $ai->agent()->withTools([new Tools\PdoQuery()]);
$answer = $agent->run('How many products in the gold-plating category?');

foreach ($agent->trace() as $step) {
    echo "Step {$step['step']}: action={$step['action']}\n  obs={$step['observation']}\n";
}
```

Useful for debugging, audit trails, or building a "show your work" UI.

## Safety

- **`PdoQuery` blocks writes.** If you want write access, write a more privileged subclass with an audit trail. Don't lift the SELECT-only restriction in the built-in tool.
- **`HttpGet` allows any URL by default.** Lock down via an allowlist in `[AI] http_allowed_hosts[]` (planned), or write a `RestrictedHttpGet` subclass that filters URLs.
- **`FileRead` blocks `..`.** It's confined to the application root.
- **Max iterations.** `agent.max_iterations = 6` in the INI prevents runaway loops. Raise carefully.
- **Tool timeout.** `agent.tool_timeout = 30` (seconds). A tool that hangs won't hold the request forever.

## Common pitfalls

- **Forgetting `withTools()`.** Without tools, the agent is just a regular `Chat`.
- **Letting the agent see secrets.** Never put API keys, raw passwords, or PII into a tool's response — the model receives the full string.
- **Long tool outputs.** Each observation is appended to the conversation. A tool that dumps 50 KB will exhaust context fast. The built-in tools cap at 8 KB; do the same in your custom tools.
- **No tool-call in reply = final answer.** If the model produces a final answer that *looks like* a tool call but doesn't validate, the agent treats it as final. Be explicit in the prompt: "Output a tool call OR a final answer, never both."
