# Multi-Agent Safety: Designing Recipient Allowlists and Human-in-the-Loop Approval Holds in TypeScript

> Source: <https://dev.to/dev_kestrel/multi-agent-safety-designing-recipient-allowlists-and-human-in-the-loop-approval-holds-in-52c1>
> Published: 2026-11-09 19:40:59+00:00

As autonomous agents transition from read-only coding assistants to proactive systems that deploy code, run marketing workflows, and message stakeholders, security and guardrail design become critical.

Giving an LLM direct access to an unconstrained outbound action (e.g., sending an email, refunding a charge, or deleting a cloud resource) without deterministic safeguards is an accident waiting to happen.

Prompt engineering alone (*"Please make sure not to email anyone outside the company"*) is **not** a security boundary. Models can be jailbroken, misinterpret context, or suffer from semantic drift.

Let's look at how to build deterministic **Safety Envelopes**: **Recipient Allowlists** and **Approval Holds**.

In production systems, your safety guardrails must exist in code outside the LLM context window:

```
[Agent Reasoner] ──> [Tool Request] ──> [Deterministic Gate] ──> [Real World API]
                                               │
                                      (Violates Allowlist?)
                                               │
                                               ▼
                                      [Hold for Approval]
```

Here is a TypeScript utility that verifies target recipients against exact matches, wildcard patterns, and corporate domains before any network packet is dispatched:

```
export class RecipientAllowlistGate {
  private allowedDomains: Set<string>;
  private explicitAddresses: Set<string>;

  constructor(domains: string[], exactAddresses: string[] = []) {
    this.allowedDomains = new Set(domains.map((d) => d.toLowerCase().replace(/^@/, "")));
    this.explicitAddresses = new Set(exactAddresses.map((a) => a.toLowerCase()));
  }

  public isAllowed(recipientEmail: string): boolean {
    const cleanEmail = recipientEmail.trim().toLowerCase();

    // Check 1: Explicit address match
    if (this.explicitAddresses.has(cleanEmail)) {
      return true;
    }

    // Check 2: Domain match
    const domain = cleanEmail.split("@")[1];
    if (domain && this.allowedDomains.has(domain)) {
      return true;
    }

    return false;
  }

  public assertAllowed(recipientEmail: string): void {
    if (!this.isAllowed(recipientEmail)) {
      throw new Error(
        `🛡️ Security Guardrail: Outbound dispatch to '${recipientEmail}' blocked. Recipient is not in the authorized company allowlist.`
      );
    }
  }
}
```

What happens when an agent genuinely needs to message an external party or execute a sensitive mutation?

Rather than failing outright or sending without confirmation, the tool creates an **Approval Hold** and returns a hold ticket to the agent:

```
export interface HoldTicket {
  ticketId: string;
  action: string;
  payload: Record<string, unknown>;
  createdAt: string;
  status: "pending_approval" | "approved" | "rejected";
}

export class ApprovalHoldRegistry {
  private tickets = new Map<string, HoldTicket>();

  public createHold(action: string, payload: Record<string, unknown>): HoldTicket {
    const ticketId = `hold_${Math.random().toString(36).substring(2, 10)}`;
    const ticket: HoldTicket = {
      ticketId,
      action,
      payload,
      createdAt: new Date().toISOString(),
      status: "pending_approval",
    };

    this.tickets.set(ticketId, ticket);
    return ticket;
  }

  public approve(ticketId: string): HoldTicket {
    const ticket = this.tickets.get(ticketId);
    if (!ticket) throw new Error("Ticket not found");
    ticket.status = "approved";
    return ticket;
  }
}
```

When the agent attempts to execute an outbound side-effect:

``` js
const gate = new RecipientAllowlistGate(["mycompany.com", "partnerdev.io"]);
const holds = new ApprovalHoldRegistry();

async function executeOutboundTool(to: string, subject: string, body: string) {
  // 1. If recipient is internal / authorized, dispatch immediately
  if (gate.isAllowed(to)) {
    return await mailer.send({ to, subject, body });
  }

  // 2. If external, stage an Approval Hold
  const hold = holds.createHold("send_email", { to, subject, body });

  return {
    isError: false,
    content: [
      {
        type: "text",
        text: `⏳ ACTION HELD FOR HUMAN APPROVAL: The recipient '${to}' is outside the verified domain allowlist.\nTicket ID: ${hold.ticketId}\nStatus: Waiting for admin approval. Notify the user to approve this ticket before retrying.`,
      },
    ],
  };
}
```

💡 **Developer Infrastructure Note**: If you need pre-built deterministic allowlists without maintaining custom middleware, check out [SadaSend](https://www.sadasend.com/?utm_source=dev.to&utm_medium=article&utm_campaign=dan_kestrel&utm_content=multi_agent_safety_allowlists). It provides per-API-key recipient allowlists, automated approval holds, and a hosted MCP server specifically for AI agents.
