- The model proposes. An LLM turns each incoming email into a structured reply and reports four risk flags.
- Application code decides. Replies with no policy holds are sent, while sensitive replies remain unsent as AgentMail Drafts.
- A person controls the held action. The reviewer edits, approves, or rejects each Draft, and a local JSON file records the decision.
Human-in-the-loop agents automate routine work while reserving sensitive decisions for people. This article explains the pattern, builds a working email agent with an enforceable review checkpoint, and shows how AgentMail provides the Drafts, permissions, labels, and allowlists needed to implement it.
The example uses an LLM to draft replies and application code to decide which replies require review. AgentMail supplies the email-side controls, while the application defines the review policy and records what the reviewer did. If you are starting with inbox setup, see how to give a support agent an email inbox. The implementation also preserves email threading for AI agents, and the production checklist links this polling demo to an AgentMail webhook architecture.

What makes this a human-in-the-loop agent?
A human-in-the-loop agent pauses before a defined action and gives a person authority to approve, change, or reject it.
Google Cloud describes the pattern as a predefined checkpoint where execution pauses while a person reviews the work, corrects an error, or provides missing input.
This email agent implements that checkpoint in three steps:
- The LLM proposes a reply and reports four risk flags.
- Application code checks those flags and the sender against a fixed policy.
- A held reply remains unsent as an AgentMail Draft until a person edits, approves, or rejects it.
The third step puts the reviewer before the external action. Reviewing a log after an email has been sent provides oversight, but it cannot prevent the send.
This design uses selective review. Routine replies can proceed automatically, while replies involving financial authority, sensitive content, or unknown contacts wait for a person. The policy is deterministic: the model can report risk, but it cannot remove a hold or call AgentMail.
When should an AI agent ask a human?
An agent should stop before an action that exceeds its authority or depends on context it does not have. For an email agent, common review triggers include:
- a refund, fee waiver, contract term, or other commitment;
- an unknown sender or recipient;
- sensitive account, identity, legal, or security information;
- missing or conflicting customer context; and
- a tool failure or model response that does not match the required schema.
This demo uses four explicit holds: refund requests, fee waivers, sensitive content, and unknown contacts. A production policy could also check transaction amounts, account status, required evidence, and category-specific error rates. These rules belong in application code so a prompt change cannot bypass them.
How the responsibilities are divided
A human-in-the-loop agent needs a clear boundary between proposing an action and authorizing it in practice. This implementation divides the work as follows:
| Component | Responsibility |
|---|---|
| LLM | Writes a reply proposal and reports four risk flags. It cannot call AgentMail. |
| Application | Checks the proposal against a fixed policy, chooses whether to send or hold it, and records the decision. |
| AgentMail | Receives the email, preserves its thread, stores held replies as Drafts, sends approved replies, and enforces allowlists and API-key permissions. |
| Reviewer | Reads the proposed reply and hold reason, then edits, approves, or rejects the Draft. |
AgentMail supplies the inbox and the primitives needed to pause and resume the email action. The application supplies the business rules that decide whether a refund is safe or a fee waiver requires review. This demo stores its queue state locally; a larger system could use AgentMail labels to mark escalations and organization-wide Draft listing to populate a shared review interface.
The code is split by responsibility. draft.js turns an incoming email into a structured reply proposal, policy.js applies the review rules, and agent.js handles the AgentMail calls, local review queue, and audit log. Local JSON state keeps the approval flow easy to inspect; a production service would use a durable database and reviewer interface.
You operate the agent with five commands. The mail operations use the current AgentMail Node SDK reference:
node agent.js check # read new mail, send replies with no holds, queue the rest
node agent.js queue # show what's waiting for you
node agent.js approve <n> # send a queued reply
node agent.js edit <n> "new text" # rewrite a queued reply first
node agent.js reject <n> # delete a queued reply, keep the recordThe model writes the reply
When check finds a new email, it sends the sender, subject, and body to OpenAI. The request uses a JSON schema that returns the proposed reply and four risk flags. The draft.js file contains this model call; its output feeds the mail operations documented in the AgentMail Node SDK reference:
// OpenAI returns the reply fields and four risk flags as strict JSON.
import OpenAI from "openai";
const proposalSchema = {
type: "object", additionalProperties: false,
properties: {
to: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 1 },
subject: { type: "string" }, text: { type: "string" },
sensitiveCategory: { type: "boolean" }, newRecipient: { type: "boolean" },
externalRecipient: { type: "boolean" }, irreversiblePromise: { type: "boolean" }
},
required: ["to", "subject", "text", "sensitiveCategory", "newRecipient", "externalRecipient", "irreversiblePromise"]
};
export async function draftReply(inbound) {
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const response = await client.responses.create({
model: "gpt-4o-mini",
instructions:
`Draft a short, polite reply to this email, addressed to its sender. ` +
`Assess your own draft honestly: set sensitiveCategory for refunds, fee changes, exceptions, or other high-stakes requests; ` +
`set irreversiblePromise if the reply commits to something that cannot be taken back; ` +
`set newRecipient or externalRecipient only if the reply is addressed to someone other than the sender. ` +
`Never promise anything on behalf of the business without flagging it.`,
input: JSON.stringify(inbound),
text: { format: { type: "json_schema", name: "email_reply", strict: true, schema: proposalSchema } }
});
return JSON.parse(response.output_text);
}The model's self-assessment is only one input. The policy combines those flags with the application's known contacts list, and any hold sends the reply to review. Only application code can call AgentMail.
The policy is twelve lines
The policy.js file checks every reply before the app can send it to an external recipient. Any hold puts the reply in the review queue instead of the outbox. The result controls the operations documented in the AgentMail Node SDK reference:
// The policy checks every reply before sending. Any hold sends the reply to
// the review queue.
export function decide(reply, knownContacts) {
const holds = [];
const to = reply.to[0];
if (!knownContacts.includes(to)) holds.push("recipient is not a known contact");
if (reply.newRecipient) holds.push("reply goes to a new recipient");
if (reply.externalRecipient) holds.push("reply goes outside the known contacts");
if (reply.sensitiveCategory) holds.push("touches a sensitive topic");
if (reply.irreversiblePromise) holds.push("promises something we can't take back");
return { action: holds.length ? "hold" : "send", holds };
}The policy uses binary holds instead of a tunable score. Each hold includes a plain-language reason that appears beside the queued reply.
The app uses three AgentMail operations. A reply with no holds goes out through messages.reply in the original conversation. A held reply becomes an unsent Draft through drafts.create, which AgentMail defines as an unsent message. The app calls drafts.send only after a person approves the Draft.
Build the mail loop and review queue
The agent.js file contains the mail loop, review queue, reviewer commands, and audit record. Its method signatures match the AgentMail Node SDK reference:
// A human-in-the-loop email agent in one file.
// node agent.js check read new mail, send replies with no holds, queue the rest
// node agent.js queue show what's waiting for you
// node agent.js approve <n> send a queued reply
// node agent.js edit <n> "new text" rewrite a queued reply, keep it queued
// node agent.js reject <n> delete a queued reply, keep the record
import { AgentMailClient } from "agentmail";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { decide } from "./policy.js";
import { draftReply } from "./draft.js";
const AGENT_INBOX = process.env.AGENT_INBOX;
const KNOWN_CONTACTS = (process.env.KNOWN_CONTACTS ?? "").split(",").map(s => s.trim()).filter(Boolean);
const client = new AgentMailClient({ apiKey: process.env.AGENTMAIL_API_KEY });
// One JSON file stores processed message IDs, queued replies, and the action log.
const STATE_PATH = "data/state.json";
mkdirSync("data", { recursive: true });
const firstRun = !existsSync(STATE_PATH);
const state = firstRun
? { processed: [], queue: [], log: [] }
: JSON.parse(readFileSync(STATE_PATH, "utf8"));
const save = () => writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + "\n");
const senderAddress = (from) => (from.match(/<([^>]+)>/)?.[1] ?? from).trim();
// Idempotency keys allow only A-Z a-z 0-9 - . _ ~ so hash anything else.
const idemKey = (prefix, value) => `${prefix}-${createHash("sha256").update(value).digest("hex").slice(0, 20)}`;
async function check() {
const { messages } = await client.inboxes.messages.list(AGENT_INBOX, {});
// On the first run, mark existing messages as history. Process only mail
// that arrives after the agent starts watching.
if (firstRun) {
state.processed = (messages ?? []).map(m => m.messageId);
save();
console.log(`adopted ${state.processed.length} existing message(s) as history -- watching for new mail from here`);
return;
}
const fresh = (messages ?? [])
.filter(m => m.labels.includes("received") && !state.processed.includes(m.messageId))
.reverse(); // oldest first
if (fresh.length === 0) { console.log("Inbox clear. Nothing new."); return; }
for (const item of fresh) {
try {
const message = await client.inboxes.messages.get(AGENT_INBOX, item.messageId);
const from = senderAddress(message.from);
const reply = await draftReply({ from, subject: message.subject, text: message.text ?? "" });
const verdict = decide(reply, KNOWN_CONTACTS);
state.processed.push(item.messageId);
if (verdict.action === "send") {
const sent = await client.inboxes.messages.reply(
AGENT_INBOX, item.messageId, { text: reply.text }, { idempotencyKey: idemKey("reply", item.messageId) },
);
state.log.push({ at: new Date().toISOString(), action: "auto-sent", to: from, subject: message.subject, messageId: sent.messageId, threadId: sent.threadId });
console.log(`sent "${message.subject}" -- replied to ${from}`);
} else {
const draft = await client.inboxes.drafts.create(AGENT_INBOX, {
to: [from], subject: `Re: ${message.subject}`, text: reply.text, inReplyTo: item.messageId,
});
const entry = {
n: state.queue.length + 1, status: "pending", draftId: draft.draftId,
to: from, subject: message.subject, replyText: reply.text, holds: verdict.holds,
};
state.queue.push(entry);
console.log(`queued "${message.subject}" -> #${entry.n} (${verdict.holds.join("; ")})`);
}
} catch (error) {
// One bad message (or a provider rejection) must not stop the mail
// run. Record it, leave the message unprocessed so the next check
// retries it, and keep going.
state.processed = state.processed.filter(id => id !== item.messageId);
state.log.push({ at: new Date().toISOString(), action: "error", messageId: item.messageId, error: String(error?.message ?? error).slice(0, 200) });
console.log(`error "${item.subject ?? item.messageId}" -- ${String(error?.message ?? error).slice(0, 80)}`);
}
save();
}
}
function queue() {
const pending = state.queue.filter(q => q.status === "pending");
if (pending.length === 0) { console.log("Queue empty. The agent has nothing waiting on you."); return; }
for (const q of pending) {
console.log(`#${q.n} to ${q.to} re "${q.subject}"`);
console.log(` held because: ${q.holds.join("; ")}`);
console.log(` draft reply: ${q.replyText.slice(0, 160)}${q.replyText.length > 160 ? "..." : ""}`);
}
}
function entryOrDie(n) {
const q = state.queue.find(e => e.n === Number(n) && e.status === "pending");
if (!q) { console.error(`No pending queue entry #${n}.`); process.exit(1); }
return q;
}
async function approve(n) {
const q = entryOrDie(n);
const sent = await client.inboxes.drafts.send(AGENT_INBOX, q.draftId, {}, { idempotencyKey: idemKey("approve", q.draftId) });
q.status = "approved";
state.log.push({ at: new Date().toISOString(), action: "approved", n: q.n, to: q.to, subject: q.subject, messageId: sent.messageId, threadId: sent.threadId });
save();
console.log(`approved #${q.n} -> sent to ${q.to}`);
}
async function edit(n, text) {
const q = entryOrDie(n);
if (!text) { console.error("Usage: edit <n> \"new reply text\""); process.exit(1); }
await client.inboxes.drafts.update(AGENT_INBOX, q.draftId, { text });
q.replyText = text;
state.log.push({ at: new Date().toISOString(), action: "edited", n: q.n });
save();
console.log(`edited #${q.n} -- still queued, approve when ready`);
}
async function reject(n) {
const q = entryOrDie(n);
await client.inboxes.drafts.delete(AGENT_INBOX, q.draftId);
q.status = "rejected";
state.log.push({ at: new Date().toISOString(), action: "rejected", n: q.n, to: q.to, subject: q.subject, outcome: "draft_deleted_no_send" });
save();
console.log(`rejected #${q.n} -- draft deleted, nothing sent, record kept`);
}
const [command, arg, ...rest] = process.argv.slice(2);
if (command === "check") await check();
else if (command === "queue") queue();
else if (command === "approve") await approve(arg);
else if (command === "edit") await edit(arg, rest.join(" "));
else if (command === "reject") await reject(arg);
else { console.error("Usage: node agent.js check | queue | approve <n> | edit <n> \"text\" | reject <n>"); process.exit(1); }The file handles retries and repeated commands in three places:
- Every send carries an idempotency key hashed from a stable ID because keys allow only
A-Z a-z 0-9 - . _ ~. - A failed message is removed from
processed, so the nextcheckretries it without stopping the rest of the run. - The reviewer commands work only on a
pendingentry, so the same item cannot be approved or rejected twice.
Run it
You need Node.js 18+, two packages, and four environment variables to run this local email agent. Install openai and agentmail as named by the AgentMail Node SDK reference:
bun add openai agentmail
export OPENAI_API_KEY="..."
export AGENTMAIL_API_KEY="..."
export AGENT_INBOX="your-agent@agentmail.to"
export KNOWN_CONTACTS="your-customer@example.com"Start with the first check, using the client initialized from the AgentMail Node SDK reference:
node agent.js checkThe first run produced this output with the AgentMail Node SDK reference:
adopted 128 existing message(s) as history -- watching for new mail from hereOn its first run, the agent marks existing messages as processed without replying to them. An early version treated 63 old test emails as new work. The current version acts only on mail that arrives after it starts watching.
We then sent three emails from the customer's mailbox: an everyday question, a refund demand, and a request for a permanent fee waiver. The next check used the AgentMail Node SDK reference to read all three:
node agent.js checkThat run returned the following AgentMail results, using the same Node SDK reference:
error "Quick question [9153ed93]" -- MessageRejectedError
Status code: 403
Body: {
"name": "MessageRejectedError",
queued "Refund request [9153ed93]" -> #1 (touches a sensitive topic)
queued "Permanent fee waiver [9153ed93]" -> #2 (touches a sensitive topic)The policy queued the refund and fee-waiver replies with their hold reasons. The everyday reply passed the policy, but AgentMail returned 403 because the recipient was not on the account's send allow list. The agent logged the error, left the message unprocessed, and continued with the other messages.
We added the recipient to the allow list and ran check again through the AgentMail Node SDK:
node agent.js checkThe retried SDK call produced this output, based on the AgentMail Node SDK reference:
sent "Quick question [9153ed93]" -- replied to ritik-manicule@agentmail.toThe retry succeeded because the first attempt left the message unprocessed. AgentMail sent the reply in the sender's original conversation.
Clear the queue
The queue command displays Drafts created through the AgentMail Node SDK:
node agent.js queueThe local queue contained these entries from the AgentMail Draft flow:
#1 to ritik-manicule@agentmail.to re "Refund request [9153ed93]"
held because: touches a sensitive topic
draft reply: Dear Ritik,
Thank you for your email regarding your refund request. I will need to review your account details and request before I can provide a response. Ple...
#2 to ritik-manicule@agentmail.to re "Permanent fee waiver [9153ed93]"
held because: touches a sensitive topic
draft reply: Dear Ritik,
Thank you for your email. I appreciate your request regarding the permanent fee waiver. I will need to discuss this matter further with the appropr...The model drafted a generic acknowledgment because it lacked the account context and authority to approve a refund. We replaced the acknowledgment with the actual decision, then approved it through the AgentMail Draft flow:
node agent.js edit 1 "Hi Ritik, thanks for reaching out. I've checked your account and processed the refund for the last three months today. You'll see it on your statement within 5 business days. Sorry for the trouble, and let me know if anything else comes up."The edit kept the Draft unsent, as described in the AgentMail Draft documentation:
edited #1 -- still queued, approve when readyApproval calls drafts.send from the AgentMail Node SDK reference:
node agent.js approve 1The approved Draft returned this result from the AgentMail Node SDK:
approved #1 -> sent to ritik-manicule@agentmail.toApproval sends the stored Draft by its ID through drafts.send, using a separate idempotency key. The policy required review, and the reviewer chose the final text. The record stores both actions separately.
We rejected the fee-waiver Draft. Without a reject action, reviewers could only delay or send a held reply. Rejection deletes the unsent Draft through the AgentMail Node SDK:
node agent.js reject 2The delete operation produced this local result, following the AgentMail Draft flow:
rejected #2 -- draft deleted, nothing sent, record keptThe customer's mailbox received these two replies, sent in their original threads through the AgentMail Node SDK:
received "Re: Refund request [9153ed93]" from ritik-test2@agentmail.to (thread 4458102e-c56e-471c-9ce9-555cb297297f)
preview: Hi Ritik, thanks for reaching out. I've checked your account and processed the refund for the last three month
received "Re: Quick question [9153ed93]" from ritik-test2@agentmail.to (thread 7dd7b2fa-5235-4b39-864e-fbb94f3dfdc5)
preview: Hi Ritik,
Yes, our email support channel is now fully operational. Feel free to send over your requests anyti
The customer's mailbox after the run. The refund thread contains the human-edited reply, and its thread ID matches the terminal output.
The everyday and refund replies appear in their original conversations. AgentMail sent no reply to the fee-waiver request.
Record each decision
The agent writes every reviewer and send action to one JSON audit file during the run. Those actions correspond to the send and Draft operations in the AgentMail Node SDK reference:
[
{
"at": "2026-08-27T17:53:44.740Z",
"action": "auto-sent",
"to": "ritik-manicule@agentmail.to",
"subject": "Quick question [9153ed93]"
},
{
"at": "2026-08-27T17:54:10.620Z",
"action": "edited",
"n": 1
},
{
"at": "2026-08-27T17:54:16.280Z",
"action": "approved",
"n": 1,
"to": "ritik-manicule@agentmail.to",
"subject": "Refund request [9153ed93]"
},
{
"at": "2026-08-27T17:54:19.382Z",
"action": "rejected",
"n": 2,
"to": "ritik-manicule@agentmail.to",
"subject": "Permanent fee waiver [9153ed93]",
"outcome": "draft_deleted_no_send"
}
]The log shows that the agent sent the everyday reply, a person edited and approved the refund, and the reviewer rejected the fee waiver. Sent mail records only the first two outcomes, while the local log also preserves the rejected action.
What should you harden before production?
A production review system needs durable state, authenticated reviewer actions, event-driven intake, scoped credentials, and evidence for every policy decision.
- Replace polling with a
message.receivedwebhook so AgentMail pushes mail to your app. The webhook handler can reuse the check logic. - Treat every inbound email as untrusted input. Validate senders before content reaches the model, and constrain model output to the fields in the proposal schema.
- Keep
OPENAI_API_KEYin the drafting layer only, and never expose an AgentMail send operation as a model tool. - Use scoped AgentMail keys. A process that only prepares held replies can receive
draft_createwithoutdraft_send; keepdraft_sendin the reviewer-controlled path. - Move the JSON file to a durable store with access controls once more than one process or reviewer acts on the queue.
- Keep AgentMail's send allow list on. It blocked a recipient that the application policy had allowed.
- Widen the agent's autonomy per category only after the queue records show that reviewers consistently approve those replies without changes.
AgentMail gives your agents real inboxes. Create inboxes via API. Send and receive Emails with 0 complexity. Free to start.


