After Part 2, Nimbus can look up an order through real tool calling. That’s useful, but it doesn’t solve the problem we started with in Part 1:
"I bought the Pro annual plan three days ago. Please refund order #1042."
To handle this properly, Nimbus needs to do two things a support person would do: check the order against the refund policy, and hand the refund to the team in a way that won’t get lost. In this part we build both as tools: a refund-eligibility check and a support-ticket writer. Along the way, we cover what separates AI agent tools in PHP that work reliably from ones that confuse the model.

Before you start
- What you’ll build: two new tools:
check_refund_eligibilityandcreate_support_ticket, plus a newticketstable.- You need: the setup from Part 1 and the adapter from Part 2.
- Code for this part: GitHub →
part-03🔀 Gemini or OpenAI? The code for this series comes in two versions:
Gemini Setup(free tier, recommended for learning) andOpenAI Setup(paid). The agent logic is the same in both. Only the small layer that talks to the AI provider differs, because each provider formats tool calls slightly differently, so the code may vary a bit between the two. Model names and free-tier limits change often: check.env.examplefor the current defaults.
What makes a good tool
In Part 2 we had one tool, so the model never had to choose. With three, choosing correctly becomes the main challenge. These five rules make the difference.
1. One clear job per tool. check_refund_eligibility checks; it doesn’t refund. create_support_ticket creates a ticket; it doesn’t decide anything. A tool that does several things based on a mode parameter is harder for the model to use correctly.
2. Name it like a function: verb + noun. lookup_order, check_refund_eligibility, create_support_ticket. The model reads the name first. refund alone is ambiguous: does it check a refund or issue one?
3. Say when to use it, and when NOT to. Two of our tools both take an order number. Without guidance, the model might use lookup_order to answer "can I get a refund?" and then do the date maths itself. So each description says where the boundary is.
4. Put business rules in code, not in the model. The refund policy is "within 14 days, paid orders only". A model can read that rule, but it’s unreliable at counting days between dates, and it might be persuaded by a customer to bend the rule. So the rule lives in PHP, and the model only receives the verdict: eligible or not, and why.
5. Return errors as data, with a reason. If a tool fails, return something like {"error": "order 9999 not found"} instead of throwing an exception. The model can then explain the problem to the customer or ask for the right information.
There’s one more distinction to understand before we write code:
- Read tools only look at data (
lookup_order,check_refund_eligibility). If the model calls them by mistake, nothing changes. - Write tools change data (
create_support_ticket). A mistake leaves something behind: a wrong or duplicate ticket. Write tools need stricter validation, which is whycreateTicket()below checks every input.

Build it
This part’s folder is part-03. It reuses seed.sql, db.php, orders.php, show-order.php and llm.php unchanged from Part 2. The adapter doesn’t change at all when we add tools: that’s the payoff from Part 2.
Step 1: Add the tickets table
Our first write action needs somewhere to write. The schema now includes a tickets table:
-- schema.sql — customers, their orders, and (new in Part 3) support tickets.
-- Uses the same "nimbus" database as the RAG series (new tables only).
CREATE DATABASE IF NOT EXISTS nimbus CHARACTER SET utf8mb4;
USE nimbus;
DROP TABLE IF EXISTS tickets;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS customers;
CREATE TABLE customers (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(190) NOT NULL UNIQUE
) CHARACTER SET utf8mb4;
CREATE TABLE orders (
id INT PRIMARY KEY, -- the order number customers see, e.g. 1042
customer_id INT NOT NULL,
plan VARCHAR(50) NOT NULL, -- which Nimbus plan they bought
amount DECIMAL(8,2) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'USD',
status ENUM('paid','refunded','cancelled') NOT NULL DEFAULT 'paid',
purchased_at DATETIME NOT NULL,
FOREIGN KEY (customer_id) REFERENCES customers(id)
) CHARACTER SET utf8mb4;
-- New in Part 3: the first table our agent can WRITE to.
CREATE TABLE tickets (
id INT AUTO_INCREMENT PRIMARY KEY,
order_id INT NULL, -- optional: not every issue is about an order
category ENUM('refund','billing','technical','account','other') NOT NULL,
summary VARCHAR(500) NOT NULL, -- written by the agent from the conversation
status ENUM('open','closed') NOT NULL DEFAULT 'open',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id)
) AUTO_INCREMENT = 5001 CHARACTER SET utf8mb4;
Re-run the schema and the seed data (this resets the sample orders too):
cd part-03
mysql -u root -p < schema.sql
mysql -u root -p < seed.sql
Ticket numbers start at 5001, so they never look like order numbers. That matters because the model sees both kinds of number in the same conversation.
Step 2: The refund policy, written as code
Nimbus’s knowledge base says: "Customers may request a full refund within 14 days of purchase." Here’s that sentence turned into PHP:
<?php
// refunds.php — the refund policy, written as code (not left to the model).
// Policy (same as Nimbus's refund-policy.md): full refund within 14 days of purchase.
const REFUND_WINDOW_DAYS = 14;
function checkRefundEligibility(int $orderId): array {
$order = findOrder($orderId);
if ($order === null) {
return ['error' => "order $orderId not found"];
}
// Use the database clock, so "today" matches the dates stored in MySQL.
$now = new DateTime(db()->query('SELECT NOW()')->fetchColumn());
$days = (new DateTime($order['purchased_at']))->diff($now)->days;
$result = [
'order_id' => $order['id'],
'amount' => $order['amount'] . ' ' . $order['currency'],
'status' => $order['status'],
'days_since_purchase' => $days,
'refund_window_days' => REFUND_WINDOW_DAYS,
];
if ($order['status'] === 'refunded') {
return $result + ['eligible' => false, 'reason' => 'This order has already been refunded.'];
}
if ($order['status'] === 'cancelled') {
return $result + ['eligible' => false, 'reason' => 'This order was cancelled, so there is nothing to refund.'];
}
if ($days > REFUND_WINDOW_DAYS) {
return $result + ['eligible' => false,
'reason' => "Purchased $days days ago; refunds are only possible within " . REFUND_WINDOW_DAYS . ' days.'];
}
return $result + ['eligible' => true,
'reason' => 'Paid and within the refund window (' . (REFUND_WINDOW_DAYS - $days) . ' days left).'];
}
Two details:
- "Today" comes from MySQL (
SELECT NOW()), not PHP. The sample orders were created with MySQL’s clock, so using the same clock avoids an off-by-one-day error if PHP and MySQL use different time zones. - The result includes the reason and the numbers, not only
trueorfalse. The model can then say "you bought it 20 days ago, and refunds are possible within 14 days" instead of just "no".
Step 3: The ticket writer, our first write action
<?php
// tickets.php — open a support ticket. The agent's first WRITE action.
const TICKET_CATEGORIES = ['refund', 'billing', 'technical', 'account', 'other'];
function createTicket(string $category, string $summary, ?int $orderId = null): array {
// 1. Validate everything the model sent. Never trust it blindly.
if (!in_array($category, TICKET_CATEGORIES, true)) {
return ['error' => "invalid category '$category'. Use one of: " . implode(', ', TICKET_CATEGORIES)];
}
$summary = trim($summary);
if (mb_strlen($summary) < 10 || mb_strlen($summary) > 500) {
return ['error' => 'summary must be between 10 and 500 characters'];
}
if ($orderId !== null && findOrder($orderId) === null) {
return ['error' => "order $orderId not found"];
}
// 2. Don't open duplicates: reuse an open ticket for the same order + category.
if ($orderId !== null) {
$stmt = db()->prepare(
"SELECT id FROM tickets
WHERE order_id = :order AND category = :category AND status = 'open'"
);
$stmt->execute(['order' => $orderId, 'category' => $category]);
if ($existing = $stmt->fetchColumn()) {
return ['created' => false, 'ticket_id' => (int) $existing,
'message' => 'An open ticket already exists for this order and issue.'];
}
}
// 3. Write it.
$stmt = db()->prepare(
'INSERT INTO tickets (order_id, category, summary) VALUES (:order, :category, :summary)'
);
$stmt->execute(['order' => $orderId, 'category' => $category, 'summary' => $summary]);
return ['created' => true, 'ticket_id' => (int) db()->lastInsertId(), 'status' => 'open'];
}
This function takes three precautions before it writes anything:
- Validation: the category must be one of five allowed values, the summary must be a sensible length, and the order (if given) must exist. Even though the tool definition limits the category with an
enum, PHP checks it again. - No duplicates: if an open ticket already exists for the same order and issue, it returns that ticket instead of creating another. Models sometimes repeat a tool call, and customers sometimes ask twice.
- A clear result:
created: truewith the ticket number, so the model can tell the customer exactly what happened.
⚠️ Gotcha: Never let the model write raw SQL or choose column names. It only fills in values (
category,summary,order_id), and PHP inserts them with a prepared statement. That’s what keeps SQL injection out of the picture even when the "user" is an AI.
Step 4: Describe the three tools
<?php
// tools.php — the tools Nimbus may use, and the PHP that runs them.
// Each description says what the tool does, when to use it, and when NOT to.
function toolDefinitions(): array {
return [
[
'name' => 'lookup_order',
'description' => 'Look up a Nimbus order by its order number. Returns the plan, '
. 'amount, status (paid, refunded or cancelled), purchase date '
. 'and customer name. Use it when the customer asks about an '
. 'order\'s details or status. Do NOT use it to decide whether '
. 'a refund is possible; use check_refund_eligibility for that.',
'parameters' => [
'type' => 'object',
'properties' => [
'order_id' => ['type' => 'integer', 'description' => 'The order number, digits only, e.g. 1042'],
],
'required' => ['order_id'],
],
],
[
'name' => 'check_refund_eligibility',
'description' => 'Check whether an order can be refunded under Nimbus\'s refund '
. 'policy (full refund within 14 days of purchase, paid orders '
. 'only). Returns eligible true/false and the reason. Always use '
. 'this before telling a customer whether they can get a refund. '
. 'It does NOT issue a refund.',
'parameters' => [
'type' => 'object',
'properties' => [
'order_id' => ['type' => 'integer', 'description' => 'The order number, digits only, e.g. 1042'],
],
'required' => ['order_id'],
],
],
[
'name' => 'create_support_ticket',
'description' => 'Open a ticket so the human support team follows up. Use it '
. 'when the customer needs something you cannot do yourself, '
. 'such as processing an eligible refund, a billing problem, or '
. 'a technical issue. Only use it after the customer has described '
. 'the problem. Returns the ticket number.',
'parameters' => [
'type' => 'object',
'properties' => [
'category' => [
'type' => 'string',
'enum' => TICKET_CATEGORIES,
'description' => 'The type of issue',
],
'summary' => [
'type' => 'string',
'description' => 'One or two sentences for the support team: what the '
. 'customer needs, plus any facts you already checked',
],
'order_id' => [
'type' => 'integer',
'description' => 'The related order number, if there is one',
],
],
'required' => ['category', 'summary'],
],
],
];
}
// Keep digits only from an order number the model sent ("#1042" → 1042).
function cleanOrderId(mixed $value): ?int {
$digits = preg_replace('/\D/', '', (string) ($value ?? ''));
return $digits === '' ? null : (int) $digits;
}
// Run a tool the model asked for. This is the ONLY place actions happen.
// Anything not listed here is refused, whatever the model asks for.
function runTool(string $name, array $args): array {
switch ($name) {
case 'lookup_order':
$orderId = cleanOrderId($args['order_id'] ?? null) ?? 0;
return findOrder($orderId) ?? ['error' => "order $orderId not found"];
case 'check_refund_eligibility':
return checkRefundEligibility(cleanOrderId($args['order_id'] ?? null) ?? 0);
case 'create_support_ticket':
return createTicket(
(string) ($args['category'] ?? ''),
(string) ($args['summary'] ?? ''),
cleanOrderId($args['order_id'] ?? null)
);
default:
return ['error' => "unknown tool: $name"];
}
}
Read the three descriptions side by side and notice how they point to each other:
lookup_ordersays: do NOT use it to decide whether a refund is possible; usecheck_refund_eligibility.check_refund_eligibilitysays: always use this before telling a customer whether they can get a refund, and it does NOT issue a refund.create_support_ticketsays when: when the customer needs something the bot can’t do itself, and only after they’ve described the problem.
The enum on category lists the allowed values in the schema itself, so the model picks from the list instead of inventing a category like "urgent". We use the same TICKET_CATEGORIES constant in both the schema and the validation, so the two can never drift apart.
cleanOrderId() is a small helper we pulled out of Part 2’s runTool(), now that three tools need it.
Step 5: Update the system prompt
agent.php is the same as in Part 2 except for the system prompt, which now describes the refund process:
// part-03/agent.php (excerpt)
const SYSTEM_PROMPT = <<<TXT
You are Nimbus HelpDesk AI, the support assistant for Nimbus, a project-management SaaS.
Use the available tools whenever you need real data. Never guess order details,
dates or refund decisions: the tools are the source of truth.
If the customer hasn't given an order number and you need one, ask for it. Never invent one.
You cannot issue refunds yourself. For a refund request: check eligibility first.
If eligible, open a refund ticket so the team can process it. If not, explain the reason.
After opening a ticket, always give the customer the ticket number.
Keep answers brief and friendly.
TXT;
Notice the split of responsibilities. The tools know the policy; the prompt knows the procedure (check first, then open a ticket, then give the ticket number). ask-agent.php now also loads refunds.php and tickets.php, and we’ve added show-tickets.php, which lists tickets directly from MySQL so you can check what the agent wrote.

Prompt spotlight: tool descriptions that steer the choice
Here is the most useful pattern from this part. When two tools could plausibly answer the same question, write the boundary into both descriptions:
| Customer asks | Should use | What stops the wrong choice |
|---|---|---|
| "What’s the status of order 1044?" | lookup_order |
Eligibility tool is described as "whether a refund is possible" |
| "Can I get a refund on 1043?" | check_refund_eligibility |
Lookup tool says "Do NOT use it to decide whether a refund is possible" |
| "Refund 1042 please" | eligibility, then ticket | Prompt says "check eligibility first"; ticket tool says "after the customer has described the problem" |
| "Can you refund it right now?" | No tool issues refunds | Eligibility tool says "It does NOT issue a refund"; prompt says "You cannot issue refunds yourself" |
If the model picks the wrong tool, improve the description first, before you change the system prompt. That’s where the model looks when choosing.
Try it
An order outside the 14-day window:
php ask-agent.php "Can I get a refund for order #1043?"
· Model requested: check_refund_eligibility({"order_id":1043})
· PHP ran it: {"order_id":1043,"amount":"12.00 USD","status":"paid","days_since_purchase":20,"refund_window_days":14,"eligible":false,"reason":"Purchased 20 days ago; refunds are only possible within 14 days."}
I'm sorry, order #1043 isn't eligible for a refund. It was purchased 20 days ago,
and refunds are only available within 14 days of purchase.
A status question goes to the right tool:
php ask-agent.php "Was order 1044 refunded?"
· Model requested: lookup_order({"order_id":1044})
· PHP ran it: {"id":1044,"plan":"Team (annual)","amount":"960.00","currency":"USD","status":"refunded",...}
Yes, order #1044 (Team annual, $960.00) has been refunded.
A ticket for something Nimbus can’t fix itself:
php ask-agent.php "I can't log in since I reset my password. Please open a ticket."
php show-tickets.php
· Model requested: create_support_ticket({"category":"account","summary":"Customer cannot log in after resetting their password."})
· PHP ran it: {"created":true,"ticket_id":5001,"status":"open"}
I've opened ticket #5001 for you. Our support team will follow up about your login problem.
#5001 order:- account open 2026-10-21 13:40:02
Customer cannot log in after resetting their password.
Now the request we started with:
php ask-agent.php "Please refund order #1042."
· Model requested: check_refund_eligibility({"order_id":1042})
· PHP ran it: {"order_id":1042,...,"eligible":true,"reason":"Paid and within the refund window (11 days left)."}
· Model asked for another tool — the loop comes in Part 4.
Sorry, that needs more steps than I can handle yet.
Nimbus did exactly what we told it: it checked eligibility first, found the order eligible, and then wanted to open a refund ticket. But our agent only allows one round of tool calls, so it had to stop.
That’s the limit of what we’ve built so far. Real support requests often need several steps in a row, where each step depends on the result of the one before. That’s what an agent loop is for.
💡 Tip (Gemini free tier): a refund request now uses two or three requests. If you hit a
429or "quota exceeded" error, switchGEMINI_CHAT_MODELin.envto a Flash-Lite model.
Recap
- Good tools have one job, a verb + noun name, and a description that says when to use them and when not to.
- Business rules belong in PHP. The model receives the verdict and the reason, not the job of applying the policy.
- Write tools need stricter validation: allowed values, sensible lengths, existing records and duplicate protection. The model fills in values; PHP owns the SQL.
- Return errors as data so the model can explain them or ask for what’s missing.
- Adding tools didn’t change the provider adapter at all.
Next, Part 4: The Agent Loop. We let Nimbus call tools as many times as it needs, with a step limit, error handling and a full trace, so "refund order 1042" finally ends with a ticket number.
Frequently Asked Questions
As few as the job needs. A handful of well-described tools works better than dozens of overlapping ones, because the model has to choose between them on every request. If two tools are easy to confuse, merge them or make the boundary explicit in both descriptions.
Models are unreliable at date arithmetic and can be talked into exceptions by persuasive customers. Writing the rule in PHP makes it exact, testable and impossible to argue with. The model only explains the result.
It can be, if the model only supplies values and your PHP validates them and runs a prepared statement. Never let the model write SQL. Start with low-risk writes like support tickets, and add human approval for anything involving money (Part 7).
A structured error with a clear reason, such as {"error": "order 9999 not found"}. The model can then tell the customer what happened or ask for the correct details, instead of the whole request failing.
Check in code before inserting. Our createTicket() looks for an open ticket with the same order and category and returns that one instead of creating a new one.
Full code for this part: GitHub → Gemini Setup/part-03 · OpenAI Setup/part-03