DEV Community

Cover image for Stop Your AI Agent From Refunding $5,000: A Tool Approval Gate in TypeScript
Siddhant Singh
Siddhant Singh

Posted on Fully Autonomous

Stop Your AI Agent From Refunding $5,000: A Tool Approval Gate in TypeScript

Most AI agent demos let the model call any tool it likes. That's fine until the model decides a frustrated customer deserves a $5,000 refund.

Prompts like "never refund more than $100" don't enforce anything. Code does. Here's a small pattern we use in AI agent development: tag every tool with a risk level, run every tool call through a policy check, and send risky calls to a human approval queue.

It works with any model provider or agent framework, because it sits between "the model asked for a tool" and "the tool actually runs."

1. Define tools with a risk level

import { z } from "zod";

type Risk = "read" | "low_write" | "high_write";

interface Tool<I extends z.ZodTypeAny> {
  name: string;
  description: string;
  risk: Risk;
  input: I;
  run: (args: z.infer<I>) => Promise<unknown>;
}

const getOrderStatus: Tool<z.ZodObject<{ orderId: z.ZodString }>> = {
  name: "get_order_status",
  description: "Look up the status of an order by ID.",
  risk: "read",
  input: z.object({ orderId: z.string().min(1) }),
  run: async ({ orderId }) => ({ orderId, status: "shipped" }), // call your API here
};

const issueRefund: Tool<
  z.ZodObject<{ orderId: z.ZodString; amount: z.ZodNumber }>
> = {
  name: "issue_refund",
  description: "Refund part or all of an order.",
  risk: "high_write",
  input: z.object({
    orderId: z.string().min(1),
    amount: z.number().positive().max(5000),
  }),
  run: async ({ orderId, amount }) => ({ orderId, refunded: amount }),
};

const tools = [getOrderStatus, issueRefund] as const;
Enter fullscreen mode Exit fullscreen mode

The schema is your first guardrail. Bad arguments never reach your API.

2. Write the policy as plain code

type Decision =
  | { kind: "allow" }
  | { kind: "needs_approval"; reason: string }
  | { kind: "deny"; reason: string };

function policy(toolName: string, risk: Risk, args: any): Decision {
  if (risk === "read" || risk === "low_write") return { kind: "allow" };

  if (toolName === "issue_refund") {
    if (args.amount <= 50) return { kind: "allow" };
    return { kind: "needs_approval", reason: `Refund of $${args.amount}` };
  }

  return { kind: "needs_approval", reason: `High-risk tool: ${toolName}` };
}
Enter fullscreen mode Exit fullscreen mode

Keep this boring and testable. Product and compliance teams can read it, which you can't say about a 2,000-word system prompt.

3. Gate every tool call

interface PendingAction {
  id: string;
  toolName: string;
  args: unknown;
  reason: string;
  createdAt: Date;
}

const approvalQueue: PendingAction[] = [];

async function executeToolCall(toolName: string, rawArgs: unknown) {
  const tool = tools.find((t) => t.name === toolName);
  if (!tool) return { error: `Unknown tool: ${toolName}` };

  const parsed = tool.input.safeParse(rawArgs);
  if (!parsed.success) {
    // Give the model a readable error so it can retry with fixed arguments
    return { error: parsed.error.issues.map((i) => i.message).join("; ") };
  }

  const decision = policy(tool.name, tool.risk, parsed.data);
  log({ toolName, args: parsed.data, decision }); // trace every step

  if (decision.kind === "deny") return { error: decision.reason };

  if (decision.kind === "needs_approval") {
    const action: PendingAction = {
      id: crypto.randomUUID(),
      toolName,
      args: parsed.data,
      reason: decision.reason,
      createdAt: new Date(),
    };
    approvalQueue.push(action);
    return {
      status: "pending_approval",
      message: "A team member will review this request shortly.",
      actionId: action.id,
    };
  }

  return tool.run(parsed.data as any);
}

function log(entry: Record<string, unknown>) {
  console.log(JSON.stringify({ at: new Date().toISOString(), ...entry }));
}
Enter fullscreen mode Exit fullscreen mode

Your agent loop calls executeToolCall instead of running tools directly. The model gets back either a result, a readable error or a "pending approval" message it can relay to the user.

4. Approve or reject from your back office

async function approve(actionId: string, approvedBy: string) {
  const i = approvalQueue.findIndex((a) => a.id === actionId);
  if (i === -1) throw new Error("Action not found");
  const [action] = approvalQueue.splice(i, 1);

  const tool = tools.find((t) => t.name === action.toolName)!;
  const result = await tool.run(action.args as any);
  log({ event: "approved", actionId, approvedBy, result });
  return result;
}
Enter fullscreen mode Exit fullscreen mode

In production, the queue lives in your database, the approve and reject buttons live in your admin panel or Slack, and each decision is stored for audit.

5. Test the policy like any other code

import { strict as assert } from "node:assert";

assert.equal(policy("get_order_status", "read", {}).kind, "allow");
assert.equal(policy("issue_refund", "high_write", { amount: 20 }).kind, "allow");
assert.equal(
  policy("issue_refund", "high_write", { amount: 900 }).kind,
  "needs_approval",
);
Enter fullscreen mode Exit fullscreen mode

Add these to CI, next to your agent eval suite. When someone changes a threshold, the tests tell you.

Why this matters

Agents fail in production for boring reasons: a bad argument, a missing permission check, a tool that does too much. A 60-line gate catches most of them, and it lets you give the agent more autonomy later with real data behind the decision.

If you're planning AI agent development for a business system and want help with the architecture, here's how our team approaches AI agent development services and AI integration.

How do you handle risky tool calls in your agents: approval queues, hard limits, or something else?


This post was written with AI and published by the author.

Top comments (0)