- 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 PostNuvia 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 PostNuvia 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. PostNuvia 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 PostNuvia 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 PostNuvia 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 PostNuvia.
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 PostNuvia. |
| Application | Checks the proposal against a fixed policy, chooses whether to send or hold it, and records the decision. |
| PostNuvia | 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. |
PostNuvia 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 PostNuvia 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 PostNuvia 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 :
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 :
// 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 PostNuvia.
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 :
// 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 PostNuvia 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 PostNuvia 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 :
// 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 { PostNuviaClient } from "postnuvia";
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 PostNuviaClient({ apiKey: process.env.POSTNUVIA_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 postnuvia as named by the :
bun add openai postnuvia
export OPENAI_API_KEY="..."
export POSTNUVIA_API_KEY="..."
export AGENT_INBOX="your-agent@postnuvia.com"
export KNOWN_CONTACTS="your-customer@example.com"Start with the first check, using the client initialized from the :
node agent.js checkThe first run produced this output with the :
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 to read all three:
node agent.js checkThat run returned the following PostNuvia results, using the same :
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 PostNuvia 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 :
node agent.js checkThe retried SDK call produced this output, based on the :
sent "Quick question [9153ed93]" -- replied to ritik-manicule@postnuvia.comThe retry succeeded because the first attempt left the message unprocessed. PostNuvia sent the reply in the sender's original conversation.
Clear the queue
The queue command displays Drafts created through the :
node agent.js queueThe local queue contained these entries from the PostNuvia Draft flow:
#1 to ritik-manicule@postnuvia.com 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@postnuvia.com 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 PostNuvia 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 PostNuvia Draft documentation:
edited #1 -- still queued, approve when readyApproval calls drafts.send from the :
node agent.js approve 1The approved Draft returned this result from the :
approved #1 -> sent to ritik-manicule@postnuvia.comApproval 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 :
node agent.js reject 2The delete operation produced this local result, following the PostNuvia Draft flow:
rejected #2 -- draft deleted, nothing sent, record keptThe customer's mailbox received these two replies, sent in their original threads through the :
received "Re: Refund request [9153ed93]" from ritik-test2@postnuvia.com (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@postnuvia.com (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. PostNuvia 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 :
[
{
"at": "2026-08-27T17:53:44.740Z",
"action": "auto-sent",
"to": "ritik-manicule@postnuvia.com",
"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@postnuvia.com",
"subject": "Refund request [9153ed93]"
},
{
"at": "2026-08-27T17:54:19.382Z",
"action": "rejected",
"n": 2,
"to": "ritik-manicule@postnuvia.com",
"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 PostNuvia 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 PostNuvia send operation as a model tool. - Use scoped PostNuvia 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 PostNuvia'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.
PostNuvia is the first email provider built for AI agents. Create an inbox in one API call, send and receive email, no OAuth and no domain verification. Free tier, no credit card required.


