So far the tools have been read-only (weather, search). The more interesting case is a tool that changes state - placing an order, updating a record, firing an action. The agent has to query state, decide, then mutate it, all in one turn.

This example is an inventory agent: the user asks to buy something, the agent checks stock, then places the order.

Simulated state, real mutation

A plain PHP class stands in for a real database - an in-memory array of products, with methods to read and write it. This keeps the example runnable with no setup, while behaving exactly like a DB-backed store would.

class ProductDatabase { private array $products = [ 'p1' => ['name' => 'Quantum Laptop', 'price' => 1500, 'stock' => 5], 'p2' => ['name' => 'Nano Phone', 'price' => 800, 'stock' => 0], 'p3' => ['name' => 'AI Headset', 'price' => 300, 'stock' => 12], ]; public function search(string $query): array { $out = []; foreach ($this->products as $id => $p) { if (stripos($p['name'], $query) !== false) $out[$id] = $p; } return $out; } 

order() is the mutating half: it validates the product exists and has enough stock before touching anything, then decrements the count and returns a human-readable result.

 public function order(string $id, int $qty): string { if (!isset($this->products[$id])) return "Error: Product not found."; if ($this->products[$id]['stock'] < $qty) return "Error: Insufficient stock."; $this->products[$id]['stock'] -= $qty; // <-- the mutation return "Success: Ordered $qty of {$this->products[$id]['name']}. " . "New stock: {$this->products[$id]['stock']}"; } public function getInventory(): array { return $this->products; } } $db = new ProductDatabase(); 

Two tools: one reads, one writes

The read tool is a thin wrapper around $db->search() - no logic of its own, just a schema the model can call:

use NanoAgent\Agent; use NanoAgent\Tools\FunctionTool; $searchTool = new FunctionTool( name: 'search_products', description: 'Search for products by name in the store inventory.', parameters: [ 'type' => 'object', 'properties' => ['query' => ['type' => 'string', 'description' => 'Product name or keyword']], 'required' => ['query'], 'additionalProperties' => false ], callable: fn(array $args) => $db->search($args['query']) ); 

The write tool is the same shape, just pointed at $db->order() instead - the mutation itself lives entirely in the ProductDatabase class, not in the tool:

$orderTool = new FunctionTool( name: 'place_order', description: 'Place an order for a specific product by its ID.', parameters: [ 'type' => 'object', 'properties' => [ 'product_id' => ['type' => 'string', 'description' => 'The unique product ID'], 'quantity' => ['type' => 'integer', 'description' => 'Units to purchase'] ], 'required' => ['product_id', 'quantity'], 'additionalProperties' => false ], callable: fn(array $args) => $db->order($args['product_id'], $args['quantity']) ); 

The agent that runs both

$agent = new Agent( llm: $llmConfig, systemPrompt: "You are a retail sales assistant. You have tools 'search_products' and " . "'place_order'. You MUST use these tools to check availability and place " . "orders. Do not guess stock levels.", tools: [$searchTool, $orderTool] ); $agent->enableActivityLogging(); $response = $agent->chat("I want to buy 2 Quantum Laptops. Check stock and order."); echo $response; 

Check the database afterward and the stock is genuinely lower - this wasn't just a chat reply, the agent actually called place_order and it actually ran:

var_dump($db->getInventory()['p1']['stock']); // 5 -> 3 

The "do not guess stock levels" line in the prompt is load-bearing. Without it, a model might answer "yes, we have some" from memory. With it, the agent must call search_products to read the real number, then place_order to act on it.

The patterns that make stateful tools safe

  • Read before write. The agent checks stock, then orders. The mutation is grounded in a fresh read, not an assumption.
  • Return human-readable results, not raw arrays. "Success: Ordered 2... New stock: 3" lets the model reason about the outcome and report it. A bare array gives it less to work with.
  • Fail as a result, not an exception. "Error: Insufficient stock." is returned to the model, which can then tell the user "only 0 in stock" instead of your app 500ing.
  • additionalProperties => false tightens the schema so the model can't pass junk fields.
  • enableActivityLogging() so you can audit exactly which tools fired in what order - non-negotiable once tools can write.

Scaling this up

Swap ProductDatabase for real persistence and you've got an agent that can read your DB, make a decision, and write back to your DB - the core of most "agentic" business automation (orders, tickets, inventory, bookings). The agent is the decision layer; your PHP functions are the hands.

Part of the NanoAgent examples series. Landing + demos.