Skip to content

10. Approvals

Give tutorial 9’s support agent a second tool: one that refunds orders. A refund moves money, so the agent may ask for one, but nothing happens until a person approves it. If the person says no, and says why, the agent redrafts. This is how you let agents act on things with consequences, such as payments, emails to customers or deleted data, while a person keeps the final say.

You’ll learn

  • what happens to a run while a tool call waits for a person
  • how to approve or reject a call from code, the CLI, curl or the portal
  • how a rejection’s reason makes the agent redraft
  • how to describe a JSON request body that the model fills in

New concepts: approvals · run statuses · request schemas

Builds on: 9. Call your code

Time: ~20 min · Cost: under $0.01 on DeepSeek · Needs: tutorial 9’s orders API and tunnel, still running, and its orders-api-key secret

A tool with requiresApproval = true never runs on the model’s say-so alone:

1. The agent calls a tool that needs approval, such as refund-order with an amount of 12.50. 2. Maitento checks the arguments against the tool's request schema. 3. The call is held: the run's status is PendingApproval and nothing reaches your server. If a person approves, Maitento calls your server and the agent gets its answer. If a person rejects it with a reason, the agent gets the reason as the tool's result and redrafts the call, which is held again for a new approval.

  • The call is held. Maitento checks the arguments, then stops instead of calling your server.
  • The run waits. Its status becomes PendingApproval, and its pendingApproval says which tool, with which arguments. The run waits for as long as it takes. Any other tool calls the agent made in the same turn wait behind this one.
  • Approve and Maitento makes the call, gives the agent your server’s answer, and the run carries on.
  • Reject with a reason and your server is never called. The agent is told your reason as the tool’s result, and usually redrafts the call, which waits for approval again.

Approvals happen only in interactions, where a run can wait for a person. An app is a program with nobody to ask: when it calls a tool that needs approval, the call fails with External operation '…' needs a person's approval before it runs, and a program can't wait for one.

The refund connector points at tutorial 9’s server, at POST /orders/{orderId}/refund. Put your tunnel’s address in place of https://YOUR-SERVER.example.com, in this project’s two connector files, as you did in tutorial 9:

maitento/connectors/open-api/orders-refund.mtr
// Replace the host with your orders API's public URL: the tunnel address from tutorial 9.
method = "POST"
url = "https://YOUR-SERVER.example.com/orders/{orderId}/refund"
description = "Refunds part or all of an order"
auth = "Header"
schema.request = @json("refund-request")
parameters.path = [
{
name = "orderId"
description = "The order id, for example A-1001"
}
]

schema.request is a JSON Schema for the request body. The model fills it in, and Maitento checks it before the call is held, so a person is only ever asked to approve a well-formed refund:

maitento/json/refund-request.json
{
"type": "object",
"properties": {
"amount": { "type": "number", "description": "Amount to refund, in the order's currency" },
"reason": { "type": "string", "description": "Why the customer is being refunded" }
},
"required": ["amount", "reason"],
"additionalProperties": false
}

The tool sends the secret like get-order does, and needs a person’s approval:

maitento/external-operations/open-api/refund-order.mtr
connector = @ref("orders-refund")
auth = {
header = "X-Api-Key"
secret = @ref("orders-api-key")
}
guidance = "Refunds an order. Only refund what the customer asked for, and never more than the order total."
// A person must approve every refund before Maitento calls your server.
requiresApproval = true

The agent gets both tools:

maitento/agents/support-agent.mtr
// Tutorial 9's support agent, now with a refund tool as well.
model = @ref("deepseek.deepseek-v4-flash")
prompt = "You are a customer support agent for an online shop. Look orders up before you answer, and be precise about amounts."
summary = "Handles order questions and refunds"
functions = [
{
ref = @ref("get-order")
},
{
ref = @ref("refund-order")
}
]

The script checks that the orders-api-key secret exists, deploys the project, and asks for a refund. The run’s onWaiting callback is called when the run starts waiting for a person: it shows what the agent wants to do, and asks you.

import { createInterface } from "node:readline";
import { Maitento } from "@maitento/sdk";
const mt = new Maitento();
// The tools send the orders-api-key secret that tutorial 9's setup stored, so it must exist before the deploy.
if (!(await mt.secrets.findByName("orders-api-key"))) {
throw new Error("Run tutorial 9's setup first: it stores the orders-api-key secret.");
}
await mt.deploy("../maitento");
const lines = createInterface({ input: process.stdin })[Symbol.asyncIterator]();
const ask = async (prompt: string): Promise<string> => {
process.stdout.write(prompt);
const line = await lines.next();
return line.done ? "" : line.value;
};
const message = "Hi, order A-1001 arrived but the blue mug was smashed. Could I get a refund of 12.50 for it?";
console.log(`Customer: ${message}\n`);
const run = await mt.interactions.run("support", { inputs: { message } }, {
// A refund needs your approval: the run waits with status PendingApproval until you decide.
onWaiting: async (waiting) => {
const approval = waiting.pendingApproval;
if (waiting.status !== "PendingApproval" || !approval) return;
console.log(`The agent wants to call ${approval.functionName} with ${approval.argumentsJson}`);
const answer = await ask("Approve? (y/n) ");
if (answer.trim().toLowerCase().startsWith("y")) {
await mt.interactionRuns.approve(waiting.id, { approvalId: approval.id });
} else {
const reason = await ask("Why not? ");
await mt.interactionRuns.reject(waiting.id, { approvalId: approval.id, reason: reason || "Not approved" });
}
},
});
console.log(`\nSupport: ${run.output}`);
process.exit(0);

Answer y:

Customer: Hi, order A-1001 arrived but the blue mug was smashed. Could I get a refund of 12.50 for it?
The agent wants to call refund-order with {"additionalInputs":{"path.orderId":"A-1001"},"request":{"amount":12.5,"reason":"Blue mug arrived smashed"}}
Approve? (y/n) y
Support: The refund has been processed successfully.
To confirm: a refund of **£12.50** has been issued for order A-1001 covering the smashed Blue mug. The order total was £34.50, and the total refunded on this order now stands at £12.50. …

Done when: your server prints Refunded 12.5 GBP on A-1001: Blue mug arrived smashed only after you answer y, and the agent confirms the refund.

While the run waited, its pendingApproval looked like this:

{
"id": "6bc72d64-64f4-43f6-8e3c-813982067cea",
"functionName": "refund-order",
"argumentsJson": "{\"request\":{\"amount\":5,\"reason\":\"Tea sampler was missing two of its teas (two teas not received)\"},\"additionalInputs\":{\"path.orderId\":\"A-1001\"}}",
"toolCallId": "call_00_BkEIFjXX9GpahKxlIC177651",
"dateRequested": "2026-10-26T19:58:17.230Z",
"status": "Pending",
"agentId": "56ba8062-f81e-447b-b76e-984c228ca0d4",
"agentName": "Support"
}

argumentsJson holds the body (request) and the path and query values (additionalInputs), as the model wrote them.

The script answers in the same program that started the run, but it doesn’t have to. Anyone in your tenant can decide, from any of these, while the run is PendingApproval. Each needs the run’s id and the approval’s id (pendingApproval.id), which you get by reading the run:

await mt.interactionRuns.approve(run.id, { approvalId: run.pendingApproval.id });
await mt.interactionRuns.reject(run.id, { approvalId: run.pendingApproval.id, reason: "Refund the mug only" });

The CLI finds the approval the run is waiting on by itself. An approve returns once your server has answered.

In the portal, open Runs, then the run: the call waiting for approval is at the bottom of the conversation, with its arguments, Approve and run and Reject…. Rejecting asks for the reason.

4. Break it on purpose: reject, with a reason

Section titled “4. Break it on purpose: reject, with a reason”

Restart your orders API first. It keeps orders in memory, so the restart undoes step 2’s refund, and the agent won’t see one already made.

Run the script again. This time answer n, and give a reason the agent can act on. Then approve the call it redrafts:

Customer: Hi, order A-1001 arrived but the blue mug was smashed. Could I get a refund of 12.50 for it?
The agent wants to call refund-order with {"request":{"amount":12.5,"reason":"Blue mug arrived smashed (damaged in transit)"},"additionalInputs":{"path.orderId":"A-1001"}}
Approve? (y/n) n
Why not? The mug was on sale for 10.00, so refund 10.00, not 12.50.
The agent wants to call refund-order with {"additionalInputs":{"path.orderId":"A-1001"},"request":{"amount":10,"reason":"Blue mug arrived smashed (damaged in transit); refunding the £10.00 sale price"}}
Approve? (y/n) y
Support: Thank you for the correction. I've processed a refund of £10.00 (the sale price of the blue mug) against order A-1001. That brings the total refunded on the order to £10.00, out of the original order total of £34.50.

Done when: you’re asked twice, your server prints a single Refunded 10 GBP on A-1001: …, and nothing for 12.50.

What happened:

  1. The rejected call was never sent. The agent got your reason as the tool’s result, with an instruction to make the changed call if the reason asks for a change.
  2. It worked out the change itself, 10.00 instead of 12.50, and called the tool again. That was a new call, so it needed a new approval: onWaiting was called a second time, with a new pendingApproval.id.
  3. You approved it, and only then did Maitento call your server.

A reason that asks for no change (“We don’t refund damaged items”) makes the agent drop the refund and say so instead.

  • The script stops with Run tutorial 9's setup first. The orders-api-key secret doesn’t exist. Run tutorial 9’s setup, which stores it.
  • The agent says the order was already refunded, and asks nothing. Your server still has an earlier refund in memory. Restart it.
  • Interaction is not in PendingApproval state. The run isn’t waiting any more: it was already approved or rejected, or it finished. Each approval can be answered once.
  • Tool call approval ID does not match. The id you sent isn’t the approval the run is waiting on, for example one from before a redraft. Read the run again, and use its pendingApproval.id.
  • The tool result says Error connecting to remote API. The approval worked, but your server or tunnel isn’t reachable. See tutorial 9.

On GitHub: examples/10-approvals · TypeScript · Python · C#

All the files in this project (7)
// Tutorial 9's support agent, now with a refund tool as well.
model = @ref("deepseek.deepseek-v4-flash")
prompt = "You are a customer support agent for an online shop. Look orders up before you answer, and be precise about amounts."
summary = "Handles order questions and refunds"
functions = [
{
ref = @ref("get-order")
},
{
ref = @ref("refund-order")
}
]

Your support agent can now refund orders, but only the refunds a person approves. A rejection with a reason sends the agent back to redraft.

  • Approvals: requiresApproval = true on a tool holds every call to it until a person approves or rejects it.
  • PendingApproval: the run’s status while it waits. pendingApproval says which tool, with which arguments.
  • Approve or reject with the SDKs, the CLI, the API or the portal. A rejection’s reason goes to the agent.
  • schema.request: a JSON Schema the model fills in, checked before the call is held.
  • Apps can’t wait for a person, so a tool that needs approval fails when an app calls it.

Challenge: in a real shop, the person approving refunds isn’t sitting next to the customer. Start a refund request that doesn’t wait (interactions.start or mt interaction-start, as in tutorial 4), and write an approvals queue: a script that finds every support run waiting for an approval, shows what it wants to do, and approves or rejects each one.

Hint: runs.list filters runs by type and status, each run it lists names its interaction (definitionName), and interactionRuns.get returns a run with its pendingApproval.

Done when: the run you started waits, your queue shows its refund, and approving it refunds the order.

The solution is in examples/10-approvals/solution: request starts a refund, and approvals is the queue.

Run 41a8a6f6-bdb5-4155-a2f9-120b50d6b78c: Support wants to call refund-order with {"request":{"amount":5,"reason":"Tea sampler was missing two of its teas (two teas not received)"},"additionalInputs":{"path.orderId":"A-1001"}}
Approve? (y/n) y
Nothing else is waiting.

Next: 11. Know when it’s done has Maitento call your server when a run changes, for example when one starts waiting for an approval.