Tools and approval
A tool is a function an agent can call. You describe it (name, description, input schema) and
register a handler. Mark it requiresApproval: true and the agent can't call it until a human
says yes.
import { createGraph, InMemoryToolRegistry, model, type ToolId } from "@ailu-ai/graph-sdk";
const refunds: string[] = [];
// 1. Describe the tool. `jsonSchema` and `description` are what the model sees.
const tools = new InMemoryToolRegistry();
tools.register(
{
id: "refund" as ToolId,
name: "refund",
description: "Refund an order. Use only when the customer asks for a refund.",
jsonSchema: {
type: "object",
properties: { orderId: { type: "string" } },
required: ["orderId"]
},
inputSchema: { parse: (value: unknown) => value as { orderId?: string } },
outputSchema: { parse: (value: unknown) => value as { ok: boolean } },
permissions: ["payments:write"],
requiresApproval: true // a human must approve every call
},
async (input) => {
refunds.push(input.orderId ?? "unknown");
return { ok: true };
}
);
// 2. Give the tool to an agent that stops when it wants to call a gated tool.
const app = createGraph({ name: "support" })
.channel("request", { type: "string", default: "" })
.agentNode("assistant", {
model: model.anthropic("claude-sonnet-4-6"),
prompt: { system: "Help the customer. Use the refund tool when needed." },
tools,
suspendForApproval: true
})
.compile();
// 3. The run suspends before the refund runs.
const paused = await app.run({ request: "Please refund order ORD-8830." });
console.log(paused.status); // "suspended"
console.log(refunds.length); // 0: nothing ran yet
// 4. A named human approves; the agent resumes and the refund runs.
const done = await app.approveAndResume(paused.runId, {
approvedTools: ["refund"],
resolvedBy: "alice@example.com"
});
console.log(done.status); // "completed"
console.log(refunds.length); // 1
Describe a tool
InMemoryToolRegistry.register(definition, handler) takes:
| Field | Purpose |
|---|---|
id, name | The tool's identity. The model calls it by name. |
description | When to use it. The model reads this. |
jsonSchema | The JSON Schema of the input. The model reads this to build the call. |
inputSchema, outputSchema | Objects with a parse(value) method that validate the input and output in your code. A Zod schema fits. |
permissions | Labels for your own audit, such as "payments:write". |
requiresApproval | true makes every call wait for a human. |
The handler receives the parsed input and returns a JSON value, which the agent sees as the tool's result.
Tools without requiresApproval run as soon as the agent calls them.
Approve a call
With suspendForApproval: true, an agent that wants a gated tool stops the run:
run()returnsstatus: "suspended". The agent's result lists what it wants inapprovalRequests, for example{ subject: "tool:refund", reason: "..." }.app.explain(runId).summarysays the same in one sentence.- Show the request to a person in your app.
- When they approve, call
approveAndResume(runId, { approvedTools: ["refund"], resolvedBy: "<their user id>" }). The agent runs again and can now callrefund.
If they refuse, don't resume. The run stays suspended; you can discard it or keep it for the record.
resolvedBy is required: take it from your authenticated session. It is recorded with the
approval. The SDK refuses an empty value (AILU_APPROVER_REQUIRED), and the engine refuses a
grant where the approver is the agent that asked.
Keep the handler honest
The model chooses the tool's input. Treat it like user input:
- validate it in
inputSchema.parse; - check permissions in the handler (does this user own this order?);
- keep handlers idempotent where you can: a resumed run may call a tool again.
Record approvals as evidence
To sign each decision and let an auditor check it later, see Governance. To resume an approval in another process (after a deploy, or from a queue worker), see Long-running runs.
Next
- Stop the whole run, not just a tool: Human approval.
- Full app: Governed refund agent.