TUTORIALS 11 min read

Build an AI Agent Approval Queue: Human-in-the-Loop Patterns That Actually Work

Agents should not delete records, send emails, or spend money on vibes. Build an approval queue that pauses risky actions without killing your workflow.

By EgoistAI ·
Build an AI Agent Approval Queue: Human-in-the-Loop Patterns That Actually Work

An unsupervised agent with a delete button is not automation. It is a liability with good branding.

If your agent can send emails, update invoices, issue refunds, change CRM records, approve purchases, deploy code, or touch customer data, you need an ai agent approval queue. Not a vague “human-in-the-loop” checkbox. A real queue: durable, auditable, reviewable, and boring enough to trust.

The goal is simple: let the agent do the thinking and drafting, but force a human decision before anything expensive, irreversible, embarrassing, or legally spicy happens.

This tutorial shows how to build that pattern without turning your agent into a permission-requesting toddler.

What You Are Building

You are going to build a practical approval queue for AI agent actions.

The pattern looks like this:

  1. The user asks the agent to do something.
  2. The agent decides it needs to call a sensitive tool.
  3. Instead of executing immediately, the tool call becomes a pending approval.
  4. A reviewer sees the exact action, arguments, risk level, and agent reasoning.
  5. The reviewer approves, rejects, edits, or expires the action.
  6. The agent resumes with the decision and continues safely.

This is not only useful for developers. Operators, founders, support leads, RevOps teams, finance teams, and legal teams all understand one thing: “Show me what the machine is about to do before it does it.”

That is the whole game.

Prerequisites

You do not need to be a deep agent framework nerd, but you should understand the basic pieces.

You need:

  • An app where an AI agent can call tools or functions
  • A database for storing pending approvals
  • A queue or polling mechanism for reviewers
  • A web UI, Slack bot, email workflow, or admin panel for decisions
  • A way to resume the original agent run after approval

The examples below use TypeScript-style pseudocode because it is readable and maps cleanly to Node, Next.js, Express, Fastify, Hono, or most backend stacks.

You can adapt the same architecture to Python, LangGraph, OpenAI Agents SDK, Temporal, Rails, Laravel, Django, or whatever stack you already bet the company on.

The Core Pattern

A good approval queue has four layers:

1. Policy Layer

This decides whether an action needs review.

Bad policy:

if (toolName === "send_email") requireApproval = true;

Better policy:

function requiresApproval(action: AgentAction) {
  if (action.tool === "send_email" && action.args.recipient.endsWith("@customer.com")) {
    return true;
  }

  if (action.tool === "refund_payment" && action.args.amount > 50) {
    return true;
  }

  if (action.tool === "delete_record") {
    return true;
  }

  if (action.riskScore >= 7) {
    return true;
  }

  return false;
}

Expected result: low-risk actions keep moving, high-risk actions pause before execution.

That is the difference between useful automation and a permission swamp.

2. Persistence Layer

An approval that only lives in memory is cosplay.

If the process restarts, the approval should still exist. If the reviewer goes to lunch, it should still exist. If the agent run pauses overnight, it should still exist.

Use a database table like this:

create table agent_approvals (
  id text primary key,
  run_id text not null,
  user_id text not null,
  tool_name text not null,
  tool_args jsonb not null,
  tool_summary text not null,
  risk_level text not null,
  status text not null default 'pending',
  reviewer_id text,
  decision_reason text,
  expires_at timestamptz not null,
  created_at timestamptz not null default now(),
  decided_at timestamptz
);

Expected result: every sensitive action has a durable record with status, context, and decision history.

3. Review Layer

The reviewer should not see raw JSON vomit and a prayer.

Show:

  • What the agent wants to do
  • Why it wants to do it
  • Which user or system requested it
  • The exact tool arguments
  • The blast radius
  • Previous related actions
  • Approve, reject, edit, and expire controls

A useful review card might look like this:

type ApprovalCard = {
  title: "Refund customer payment";
  summary: "Refund $79 to customer_4821 for duplicate subscription charge.";
  tool: "refund_payment";
  arguments: {
    customerId: "customer_4821";
    paymentId: "pay_991";
    amount: 79;
    reason: "duplicate_charge";
  };
  riskLevel: "medium";
  requestedBy: "support-agent";
  actions: ["approve", "reject", "edit"];
};

Expected result: a human can make a decision in under 30 seconds without spelunking through logs.

4. Resume Layer

After approval, the agent should continue from where it paused.

Modern agent frameworks usually support this as an interrupt, run state, checkpoint, or resumable workflow. OpenAI’s Agents SDK uses approval interruptions and resumable run state. LangGraph uses interrupts and thread-based persistence. The names differ. The pattern is the same.

Your app needs to pass the decision back into the original run:

await resumeAgentRun({
  runId: approval.run_id,
  approvalId: approval.id,
  decision: "approved",
  editedArgs: null
});

Expected result: approval is not the end of the workflow. It is a checkpoint.

Step 1: Define Sensitive Tools

Start by listing the tools your agent can call.

Example:

const tools = [
  "search_knowledge_base",
  "draft_email",
  "send_email",
  "update_crm_contact",
  "refund_payment",
  "delete_account",
  "create_invoice",
  "deploy_code"
];

Now classify them.

const toolPolicies = {
  search_knowledge_base: {
    approval: "never",
    risk: "low"
  },
  draft_email: {
    approval: "never",
    risk: "low"
  },
  send_email: {
    approval: "conditional",
    risk: "medium"
  },
  update_crm_contact: {
    approval: "conditional",
    risk: "medium"
  },
  refund_payment: {
    approval: "conditional",
    risk: "high"
  },
  delete_account: {
    approval: "always",
    risk: "critical"
  },
  create_invoice: {
    approval: "conditional",
    risk: "high"
  },
  deploy_code: {
    approval: "always",
    risk: "critical"
  }
};

Do not treat every tool equally. Reading from a help center is not the same as deleting a customer account.

Expected result: you now have a risk map for agent behavior.

Step 2: Create an Approval Request

When the agent attempts a risky tool call, intercept it before execution.

async function handleToolCall(action: AgentAction) {
  const policy = getPolicyForTool(action.tool);

  if (!shouldPauseForApproval(action, policy)) {
    return executeTool(action.tool, action.args);
  }

  const approval = await createApprovalRequest({
    runId: action.runId,
    userId: action.userId,
    toolName: action.tool,
    toolArgs: action.args,
    toolSummary: summarizeAction(action),
    riskLevel: policy.risk,
    expiresAt: addHours(new Date(), 24)
  });

  return {
    status: "approval_required",
    approvalId: approval.id,
    message: "This action requires human approval before execution."
  };
}

The important move: the tool does not run yet.

You are not asking for forgiveness after the database is already on fire. You are asking before the match is lit.

Expected result: risky actions become pending approvals instead of immediate side effects.

Step 3: Write Better Summaries

The approval summary is where many teams get lazy.

Do not show this:

Tool call requested: refund_payment

That is useless.

Show this:

Refund $79 to customer_4821 for payment pay_991 because the agent detected a duplicate subscription charge in the billing history.

Create summaries with deterministic templates where possible.

function summarizeAction(action: AgentAction) {
  switch (action.tool) {
    case "refund_payment":
      return `Refund $${action.args.amount} to ${action.args.customerId} for payment ${action.args.paymentId}.`;

    case "send_email":
      return `Send email to ${action.args.to} with subject "${action.args.subject}".`;

    case "delete_account":
      return `Delete account ${action.args.accountId} and associated user data.`;

    default:
      return `Run ${action.tool} with provided arguments.`;
  }
}

You can use an LLM to improve the wording, but do not rely on it as the source of truth. The actual tool arguments must remain visible.

Expected result: reviewers understand what is being requested without trusting the agent’s prose.

Step 4: Add Reviewer Decisions

Your queue should support four decision types.

Approve

The tool runs exactly as requested.

await decideApproval({
  approvalId,
  reviewerId,
  decision: "approved"
});

Expected result: the original tool call executes with the original arguments.

Reject

The tool does not run. The agent receives feedback.

await decideApproval({
  approvalId,
  reviewerId,
  decision: "rejected",
  reason: "Do not refund. Customer already received account credit."
});

Expected result: the agent can continue with the rejection reason and choose a safer next step.

Edit

The reviewer changes arguments before execution.

await decideApproval({
  approvalId,
  reviewerId,
  decision: "edited",
  editedArgs: {
    amount: 39.5,
    reason: "partial_refund"
  },
  reason: "Approve partial refund only."
});

Expected result: the tool runs with reviewed arguments, not the agent’s original guess.

Expire

No decision arrives in time.

await expireOldApprovals();

Expected result: stale approvals do not sit around forever like unexploded product debt.

Step 5: Resume the Agent

Once a decision is recorded, resume the run.

async function processApprovalDecision(approvalId: string) {
  const approval = await getApproval(approvalId);

  if (approval.status === "approved") {
    const result = await executeTool(approval.tool_name, approval.tool_args);

    return resumeAgentRun({
      runId: approval.run_id,
      message: {
        type: "tool_result",
        approvalId: approval.id,
        result
      }
    });
  }

  if (approval.status === "edited") {
    const result = await executeTool(approval.tool_name, approval.edited_args);

    return resumeAgentRun({
      runId: approval.run_id,
      message: {
        type: "tool_result",
        approvalId: approval.id,
        result
      }
    });
  }

  if (approval.status === "rejected") {
    return resumeAgentRun({
      runId: approval.run_id,
      message: {
        type: "approval_rejected",
        approvalId: approval.id,
        reason: approval.decision_reason
      }
    });
  }
}

Expected result: the agent gets a structured answer and continues instead of starting over.

That matters. If every approval forces the workflow to restart, humans will hate it and developers will quietly bypass it.

Step 6: Build the Queue UI

The approval queue should be brutally clear.

Minimum columns:

  • Requested time
  • Risk level
  • Tool name
  • Summary
  • Requesting user or agent
  • Status
  • Reviewer
  • Expiration time

Example API endpoint:

app.get("/api/approvals", async (req, res) => {
  const approvals = await db.agentApprovals.findMany({
    where: {
      status: "pending"
    },
    orderBy: [
      { risk_level: "desc" },
      { created_at: "asc" }
    ]
  });

  res.json({ approvals });
});

Example decision endpoint:

app.post("/api/approvals/:id/decision", async (req, res) => {
  const { decision, reason, editedArgs } = req.body;

  const approval = await recordDecision({
    approvalId: req.params.id,
    reviewerId: req.user.id,
    decision,
    reason,
    editedArgs
  });

  await processApprovalDecision(approval.id);

  res.json({ ok: true });
});

Expected result: reviewers can work through pending agent actions like an inbox.

Keep the UI dense. This is an operations surface, not a lifestyle landing page.

Step 7: Add Notifications Without Creating Chaos

A queue nobody checks is just a graveyard with pagination.

Send notifications for high-risk or time-sensitive approvals:

async function notifyReviewers(approval: Approval) {
  if (approval.risk_level === "critical") {
    await sendSlackMessage({
      channel: "#agent-approvals",
      text: `Critical approval needed: ${approval.tool_summary}`
    });
  }

  if (approval.tool_name === "refund_payment") {
    await sendEmail({
      to: "[email protected]",
      subject: "Refund approval needed",
      body: approval.tool_summary
    });
  }
}

Do not notify everyone for everything. That is how approval systems become ignored.

Expected result: the right reviewer sees the right request at the right urgency.

Approval Rules That Actually Work

Use these patterns as your starting point.

Always Approve Reads

Most read-only actions should not need human review.

Examples:

  • Search docs
  • Look up customer profile
  • Retrieve invoice history
  • Read public web pages
  • Summarize internal knowledge base articles

Still log them. Just do not interrupt the workflow unless sensitive data access is involved.

Always Review Irreversible Writes

Some actions should always pause.

Examples:

  • Delete account
  • Cancel subscription
  • Deploy to production
  • Transfer funds
  • Send legal notice
  • Change permissions
  • Publish public content

If the action is hard to undo, review it.

Conditionally Review Medium-Risk Actions

This is where good systems beat lazy ones.

Examples:

  • Send email only if recipient is external
  • Refund only if amount exceeds a threshold
  • Update CRM only if field is revenue-related
  • Create invoice only above a dollar limit
  • Post to social only if account is public-facing

The point is not to slow the agent down. The point is to slow it down where mistakes hurt.

Common Pitfalls

Pitfall 1: Approving the Idea Instead of the Arguments

“Send the customer an email” sounds fine.

But what email? To whom? With what subject? With what attachment?

Always approve the actual tool call, not the vague intention.

Bad:

{
  "action": "send email"
}

Good:

{
  "tool": "send_email",
  "args": {
    "to": "[email protected]",
    "subject": "Refund confirmation",
    "body": "Your refund has been processed..."
  }
}

Pitfall 2: No Audit Trail

If a customer asks why something happened, “the AI did it” is not an answer. It is a confession.

Store:

  • Original request
  • Tool name
  • Original arguments
  • Edited arguments
  • Reviewer
  • Decision
  • Timestamp
  • Agent run ID
  • Result after execution

This protects users, reviewers, and the business.

Pitfall 3: Letting the Agent Self-Approve

Do not ask the same agent that proposed the action whether the action is safe.

That is not oversight. That is a mirror.

You can use automated policy checks, risk scoring, and validation. But final approval for high-impact actions should come from a human or a deterministic policy outside the agent’s control.

Pitfall 4: Making Everything Manual

If every action needs approval, people will approve blindly.

Use thresholds. Use trust levels. Use safe defaults. Use role-based policies.

Example:

if (user.plan === "enterprise" && refund.amount < 25) {
  return "auto_approve";
}

if (refund.amount >= 25 && refund.amount < 250) {
  return "human_review";
}

if (refund.amount >= 250) {
  return "manager_review";
}

The queue should catch risk, not punish usefulness.

Pitfall 5: No Expiration

Pending approvals need deadlines.

A stale approval can become dangerous because context changes. Inventory disappears. Tickets get updated. Customers reply. Prices change. Permissions move.

Use expiration:

const expiresAt = addHours(new Date(), 24);

For critical actions, use shorter windows.

Expected result: reviewers approve current actions, not ancient ghosts from a previous business reality.

Security Notes You Should Not Ignore

OWASP calls out excessive agency as a real LLM application risk: agents can cause damage when they have too much functionality, too many permissions, or too much autonomy.

Approval queues help, but they are not magic dust.

You still need:

  • Least-privilege tool permissions
  • Input validation
  • Output validation
  • Role-based access control
  • Rate limits
  • Idempotent tool execution
  • Complete audit logs
  • Separation between agent reasoning and authorization

The agent can recommend. Your system should authorize.

That line matters.

A Practical Approval Policy Template

Use this as your first draft.

const approvalPolicy = {
  read: {
    default: "allow",
    examples: ["search_docs", "get_customer", "list_orders"]
  },
  draft: {
    default: "allow",
    examples: ["draft_email", "draft_invoice", "summarize_case"]
  },
  external_write: {
    default: "review",
    examples: ["send_email", "post_social", "notify_customer"]
  },
  financial: {
    default: "review",
    thresholds: {
      autoApproveBelow: 25,
      managerReviewAbove: 250
    },
    examples: ["refund_payment", "create_invoice", "apply_credit"]
  },
  destructive: {
    default: "always_review",
    examples: ["delete_account", "remove_user", "purge_data"]
  },
  production: {
    default: "always_review",
    examples: ["deploy_code", "rotate_secret", "change_permissions"]
  }
};

Expected result: your team can reason about agent permissions before they become incidents.

Expected Final Workflow

When everything is wired correctly, the flow should feel like this:

  1. User: “Refund the customer if they were double charged.”
  2. Agent checks billing history.
  3. Agent detects a duplicate charge.
  4. Agent prepares refund_payment for $79.
  5. Approval request appears in the queue.
  6. Finance reviewer sees the customer, amount, reason, and payment ID.
  7. Reviewer approves.
  8. Refund tool executes.
  9. Agent tells the user the refund was processed.
  10. Audit log stores the entire chain.

That is the approval queue doing its job: the agent handles the work, the human owns the consequential decision.

The Takeaway

The best AI agent approval queue is not a giant red stop sign. It is a smart checkpoint.

Let agents research, draft, compare, summarize, and prepare. Make humans review the actions that affect money, customers, data, production, permissions, or public reputation.

Start with three policies today:

  • Auto-allow safe reads
  • Always review destructive actions
  • Conditionally review external, financial, and production writes

That gives you the real human-in-the-loop pattern: less theater, fewer disasters, and agents you can actually ship.

Share this article

> Want more like this?

Get the best AI insights delivered weekly.

> Related Articles

Tags

ai agentshuman-in-the-loopautomationagent workflowsapproval systemssecuritytutorials

> Stay in the loop

Weekly AI tools & insights.