An agent that can refund a payment, delete a workspace, or email a customer needs a person to say yes first. When that person is watching the chat, agent frameworks already handle the pause: the AI SDK's tool approvals, the OpenAI Agents SDK's human-in-the-loop guide, and Temporal's cookbookall show how to stop a run and wait. When the approver is a finance lead who isn't in the chat, the request goes out by email. That changes the design, because email links get opened by machines before people see them.
This walkthrough builds the email side in Next.js with React Email, Resend, and Postgres: a request that is stored before anything is sent, an email with a single review link, a review page that is safe for scanners to load, a decision that can only be recorded once, and an expiry job that declines by default. The code was type-checked and exercised against Next.js 16, React Email 6.11, and Postgres 17. The only part not run live is the Resend API call.
Why the email has no Approve button
The obvious design puts two buttons in the email: Approve and Decline, each a link to an endpoint that records the decision. It works in testing. In production, some approvals will record themselves.
Microsoft's Safe Links documentationsays that when protection is on, "URLs are scanned prior to message delivery," and URLs without a known reputation are detonated in the background. Other mail security products do the same. Supabase's auth email docsdescribe the result for sign-up links: the scanner consumes the confirmation URL, and the user sees "Token has expired or is invalid." For a confirmation link that is an annoyance. For an Approve link it is an approval nobody gave.
| Design choice | What it prevents |
|---|---|
| The email links to a review page, never to a decision | Scanners and prefetchers approving on GET |
| The decision is a POST from a form on that page | Any automated request recording a decision |
| The stored action is what executes | The agent changing the plan after approval |
| A conditional update records the decision | Double clicks and parallel tabs deciding twice |
| Requests expire and decline by default | A forgotten email approving something next month |
Each piece of the flow and the failure it rules out.
Approving by reply has a different problem. Parsing "yes" from an inbound email means trusting the From header and the words in the body, and auto-replies and quoted text make both unreliable. Keep replies for questions. Record decisions on a page you control.
What we are building
Store the request
A row with the exact action, a hashed token, and an expiry.
Create a token you never store
256 random bits in the link, only a SHA-256 hash in the database.
Write the email
What, why, how much, when it expires, and one link.
Send it once
Insert first, then send with an idempotency key.
Serve a read-only review page
Safe to load any number of times, by anyone or anything.
Record the decision once
A form POST, a conditional update, and an outbox row in one transaction.
Resume the agent and expire the rest
A worker drains the outbox; a scheduled job declines stale requests.
Step 1: Store the request before sending anything
The request row is the source of truth for everything that follows. The email, the review page, and the executor all read from it.
create table approval_requests (
id uuid primary key default gen_random_uuid(),
run_id text not null,
action jsonb not null,
summary text not null,
reason text not null,
details jsonb not null,
approver_email text not null,
token_hash text not null,
status text not null default 'pending'
check (status in ('pending', 'approved', 'rejected', 'expired')),
expires_at timestamptz not null,
decided_at timestamptz,
decision_note text,
created_at timestamptz not null default now()
);
create index approval_requests_pending_idx
on approval_requests (expires_at) where status = 'pending';
-- Outbox: written in the same transaction as the decision.
create table agent_resume_queue (
approval_id uuid primary key references approval_requests (id),
run_id text not null,
decision text not null,
created_at timestamptz not null default now(),
processed_at timestamptz
);Two columns do most of the work. action holds the exact tool call that will run if the request is approved, such as the tool name, the payment ID, and the amount in cents. details holds the human-readable version shown in the email and on the page. Keep them separate. The approver reads the details; the executor runs the action. When the agent resumes, it executes the stored action rather than regenerating one, so what was approved is what runs.
The agent_resume_queuetable is an outbox. Step 6 writes to it in the same transaction as the decision, so a recorded decision can never be lost between "saved" and "agent notified."
Step 2: Create a token you never store
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
export function createToken(): { token: string; hash: string } {
const token = randomBytes(32).toString("base64url");
return { token, hash: hashToken(token) };
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function tokenMatches(token: string, storedHash: string): boolean {
const given = Buffer.from(hashToken(token), "hex");
const stored = Buffer.from(storedHash, "hex");
return given.length === stored.length && timingSafeEqual(given, stored);
}The link carries 32 random bytes. The database stores only their SHA-256 hash, so a leaked backup or a read replica with broad access does not contain working approval links. Hashing is enough here, with no salt or slow hash, because the input is random rather than a password someone chose. The comparison uses timingSafeEqual so response time does not reveal how much of a guess matched.
Step 3: Write an email with one link and no decision buttons
An approval email is read by someone deciding whether to let software spend money or delete data. It should answer four questions without a click: what the agent wants to do, why, how big the effect is, and when the request lapses.
import * as React from "react";
import {
Html, Head, Preview, Body, Container, Section, Row, Column,
Heading, Text, Button, Link, Hr,
} from "react-email";
export interface ApprovalRequestEmailProps {
agentName: string;
summary: string;
reason: string;
details: { label: string; value: string }[];
reviewUrl: string;
expiresAt: string;
workspaceName: string;
}
export default function ApprovalRequestEmail({
agentName, summary, reason, details, reviewUrl, expiresAt, workspaceName,
}: ApprovalRequestEmailProps) {
return (
<Html lang="en">
<Head />
<Preview>{`Approval needed: ${summary}`}</Preview>
<Body style={{ margin: 0, backgroundColor: "#f3f4f6", fontFamily: "Arial, sans-serif" }}>
<Container style={{ maxWidth: "560px", padding: "32px 24px", backgroundColor: "#ffffff" }}>
<Text style={{ margin: 0, fontSize: "13px", color: "#6b7280" }}>
{agentName} is waiting for a decision
</Text>
<Heading as="h1" style={{ fontSize: "22px", lineHeight: "30px", color: "#111827" }}>
{summary}
</Heading>
<Section style={{ border: "1px solid #e5e7eb", borderRadius: "6px", padding: "8px 16px" }}>
{details.map((detail) => (
<Row key={detail.label}>
<Column style={{ width: "40%", padding: "6px 0", fontSize: "14px", color: "#6b7280" }}>
{detail.label}
</Column>
<Column style={{ padding: "6px 0", fontSize: "14px", color: "#111827" }}>
{detail.value}
</Column>
</Row>
))}
</Section>
<Text style={{ fontSize: "15px", lineHeight: "24px", color: "#374151" }}>
<strong>Why the agent wants to do this:</strong> {reason}
</Text>
<Button
href={reviewUrl}
style={{
backgroundColor: "#111827", color: "#ffffff", borderRadius: "6px",
padding: "12px 20px", fontSize: "15px", fontWeight: 600,
}}
>
Review request
</Button>
<Text style={{ fontSize: "14px", lineHeight: "22px", color: "#374151" }}>
Nothing runs until you approve on the review page. If there is no
decision by {expiresAt}, the request is declined automatically.
</Text>
<Text style={{ fontSize: "13px", color: "#6b7280" }}>
Button not working? Open this link: <Link href={reviewUrl}>{reviewUrl}</Link>
</Text>
<Hr style={{ borderColor: "#e5e7eb" }} />
<Text style={{ fontSize: "12px", color: "#9ca3af" }}>
You are an approver for {workspaceName}. Anyone with this link can
open the review page, so don't forward it.
</Text>
</Container>
</Body>
</Html>
);
}
ApprovalRequestEmail.PreviewProps = {
agentName: "Support agent",
summary: "Refund $240.00 to Northwind Traders",
reason: "The customer was charged twice for invoice INV-2291. Both charges settled on Sep 26.",
details: [
{ label: "Action", value: "Refund payment" },
{ label: "Amount", value: "$240.00" },
{ label: "Customer", value: "Northwind Traders" },
{ label: "Reversible", value: "No" },
],
reviewUrl: "https://app.example.com/approvals/7c1e?t=preview",
expiresAt: "Sep 30, 2026, 5:00 PM UTC",
workspaceName: "Acme Support",
} satisfies ApprovalRequestEmailProps;- The summary is the subject line and the heading. "Approval needed: Refund $240.00 to Northwind Traders" can be triaged from the inbox list. "Action required" cannot.
- "Reversible" is a detail row.It is the fact that most changes how carefully someone reads. Put it where it can't be missed.
- The reason is the agent's own evidence, such as the invoice number and the dates, not a generic explanation. If the agent cannot state a concrete reason, it should not be asking.
- The default is stated."If there is no decision by Sep 30, the request is declined automatically" tells the approver that ignoring the email is safe.
The preview props use app.example.com. That is fine for the preview server, and it is exactly what the React Email link checker would flag if it ever reached a real send.
Step 4: Store, then send with an idempotency key
import { Resend } from "resend";
import ApprovalRequestEmail from "@/emails/approval-request";
import { createToken } from "./approval-token";
import { appUrl } from "./app-url";
import type { AgentAction, ApprovalDetail } from "./approvals";
import { sql } from "./db";
const resend = new Resend(process.env.RESEND_API_KEY);
const TTL_MS = 24 * 60 * 60 * 1000;
export interface ApprovalInput {
runId: string;
action: AgentAction;
summary: string;
reason: string;
details: ApprovalDetail[];
approverEmail: string;
}
export async function requestApproval(input: ApprovalInput): Promise<string> {
const { token, hash } = createToken();
const expiresAt = new Date(Date.now() + TTL_MS);
const [row] = await sql<{ id: string }[]>`
insert into approval_requests
(run_id, action, summary, reason, details, approver_email, token_hash, expires_at)
values
(${input.runId}, ${sql.json(input.action)}, ${input.summary}, ${input.reason},
${sql.json(input.details)}, ${input.approverEmail}, ${hash}, ${expiresAt})
returning id
`;
if (!row) throw new Error("Failed to store approval request");
const { error } = await resend.emails.send(
{
from: "Acme Agents <agents@notifications.acme.com>",
to: input.approverEmail,
subject: `Approval needed: ${input.summary}`,
react: (
<ApprovalRequestEmail
agentName="Support agent"
summary={input.summary}
reason={input.reason}
details={input.details}
reviewUrl={appUrl(`/approvals/${row.id}`, { t: token })}
expiresAt={formatUtc(expiresAt)}
workspaceName="Acme Support"
/>
),
},
// Retries of the same request never produce a second email.
{ idempotencyKey: `approval-request/${row.id}` },
);
if (error) throw new Error(`Approval email failed: ${error.message}`);
return row.id;
}
function formatUtc(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
}).format(date) + " UTC";
}The row is written before the email is sent. If the send fails, the request exists and can be retried. If the process crashes after the provider accepted the email but before the function returned, the retry uses the same key. Resend keeps idempotency keys for 24 hours, which matches the request lifetime here, so the approver does not get two emails for one request.
The plain token exists only in memory and in the email. It is never logged or returned to the agent. The agent gets the request ID and waits.
Step 5: A review page that is safe to load
The page reads and displays. It never writes. A scanner, a link preview bot, or the approver opening the email on three devices all get the same result.
import { sql } from "./db";
import { tokenMatches } from "./approval-token";
// Type aliases (not interfaces) so postgres.js accepts them as JSON values.
export type AgentAction = {
tool: string;
input: Record<string, string | number | boolean>;
};
export type ApprovalDetail = { label: string; value: string };
export type ApprovalStatus = "pending" | "approved" | "rejected" | "expired";
export interface ApprovalForReview {
id: string;
summary: string;
reason: string;
details: ApprovalDetail[];
status: ApprovalStatus;
expiresAt: Date;
}
interface ApprovalRow {
id: string;
summary: string;
reason: string;
details: ApprovalDetail[];
status: ApprovalStatus;
expires_at: Date;
token_hash: string;
}
// Returns null for unknown IDs and wrong tokens alike, so the page can't be used to probe IDs.
export async function getApprovalForReview(
id: string,
token: string,
): Promise<ApprovalForReview | null> {
const [row] = await sql<ApprovalRow[]>`
select id, summary, reason, details, status, expires_at, token_hash
from approval_requests
where id = ${id}
`;
if (!row || !tokenMatches(token, row.token_hash)) return null;
const expired = row.status === "pending" && row.expires_at <= new Date();
return {
id: row.id,
summary: row.summary,
reason: row.reason,
details: row.details,
status: expired ? "expired" : row.status,
expiresAt: row.expires_at,
};
}import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getApprovalForReview } from "@/lib/approvals";
import { decide } from "./actions";
export const metadata: Metadata = {
title: "Review request",
robots: { index: false, follow: false },
// The token is in the URL; don't leak it to anything this page loads.
referrer: "no-referrer",
};
type Props = {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
// Read-only on GET: link scanners and prefetchers can load this safely.
export default async function ApprovalPage({ params, searchParams }: Props) {
const { id } = await params;
const { t } = await searchParams;
const token = typeof t === "string" ? t : null;
if (!token) notFound();
const request = await getApprovalForReview(id, token);
if (!request) notFound();
if (request.status !== "pending") {
return (
<main>
<h1>{request.summary}</h1>
<p>This request is {request.status}. There is nothing left to do.</p>
</main>
);
}
return (
<main>
<h1>{request.summary}</h1>
<dl>
{request.details.map((detail) => (
<div key={detail.label}>
<dt>{detail.label}</dt>
<dd>{detail.value}</dd>
</div>
))}
</dl>
<p>{request.reason}</p>
<p>Expires {request.expiresAt.toUTCString()}</p>
<form action={decide}>
<input type="hidden" name="id" value={id} />
<input type="hidden" name="token" value={token} />
<label>
Note for the audit log (optional)
<textarea name="note" maxLength={500} />
</label>
<button type="submit" name="decision" value="approved">Approve</button>
<button type="submit" name="decision" value="rejected">Decline</button>
</form>
</main>
);
}A wrong token and an unknown ID both return 404, so the page can't be used to find out which request IDs exist. The no-referrerpolicy keeps the token-bearing URL out of the Referer header once someone adds analytics, fonts, or images from another origin. Expiry is checked on read as well as by the cleanup job, so a request past its deadline shows as expired even if the job hasn't run yet.
In the test run for this post, a GET and a HEAD request to a valid review URL both returned 200 and left the request pending, while a wrong token returned 404.
Step 6: Record the decision once
The form posts to a Server Action. React includes the clicked button's name and value in the form data, so one form handles both outcomes.
"use server";
import { redirect } from "next/navigation";
import { recordDecision } from "@/lib/record-decision";
export async function decide(formData: FormData): Promise<void> {
const id = String(formData.get("id") ?? "");
const token = String(formData.get("token") ?? "");
const note = String(formData.get("note") ?? "").slice(0, 500);
const decision = formData.get("decision");
if (decision !== "approved" && decision !== "rejected") {
throw new Error("Unknown decision");
}
const result = await recordDecision({ id, token, decision, note });
redirect(`/approvals/done?result=${result}`);
}import { getApprovalForReview } from "./approvals";
import { sql } from "./db";
export type DecisionResult = "approved" | "rejected" | "already-decided" | "expired" | "invalid";
export async function recordDecision(input: {
id: string;
token: string;
decision: "approved" | "rejected";
note: string;
}): Promise<DecisionResult> {
const request = await getApprovalForReview(input.id, input.token);
if (!request) return "invalid";
if (request.status === "expired") return "expired";
const updated = await sql.begin(async (tx) => {
// The status check makes double clicks and parallel tabs harmless.
const [row] = await tx<{ run_id: string }[]>`
update approval_requests
set status = ${input.decision},
decided_at = now(),
decision_note = ${input.note || null}
where id = ${input.id}
and status = 'pending'
and expires_at > now()
returning run_id
`;
if (!row) return false;
await tx`
insert into agent_resume_queue (approval_id, run_id, decision)
values (${input.id}, ${row.run_id}, ${input.decision})
`;
return true;
});
return updated ? input.decision : "already-decided";
}The result page maps each outcome to a sentence. It needs no token and shows no request details.
const MESSAGES: Record<string, string> = {
approved: "Approved. The agent will continue.",
rejected: "Declined. The agent has been told not to proceed.",
"already-decided": "Someone already decided on this request.",
expired: "This request expired before a decision was recorded.",
invalid: "This link is not valid.",
};
export default async function DonePage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { result } = await searchParams;
const message = typeof result === "string" ? MESSAGES[result] : undefined;
return <main><p>{message ?? MESSAGES.invalid}</p></main>;
}The where status = 'pending' and expires_at > now() clause is the whole concurrency story. Postgres locks the row for the first update; the second finds the status already changed and updates nothing. Running two decisions in parallel against the same request returned ["approved", "already-decided"] and wrote one outbox row.
What we have so far
A request exists before its email does. The email carries one link to a page that only reads. The only way to change a request is a POST with a valid token while the request is pending and unexpired, and that change is recorded at most once, together with the job that will resume the agent.
Step 7: Resume the agent and expire the rest
A worker drains the outbox and hands each decision to your agent runtime. It joins the stored action so the executor never depends on the agent remembering what it asked for.
import type { AgentAction } from "./approvals";
import { sql } from "./db";
export type ResumeJob = {
approval_id: string;
run_id: string;
decision: "approved" | "rejected" | "expired";
action: AgentAction;
};
// Must be idempotent per approval_id: a failed batch is retried in full.
export type ResumeRun = (job: ResumeJob) => Promise<void>;
export async function drainResumeQueue(resume: ResumeRun): Promise<number> {
return sql.begin(async (tx) => {
const jobs = await tx<ResumeJob[]>`
select q.approval_id, q.run_id, q.decision, r.action
from agent_resume_queue q
join approval_requests r on r.id = q.approval_id
where q.processed_at is null
order by q.created_at
limit 20
for update of q skip locked
`;
for (const job of jobs) {
await resume(job);
await tx`
update agent_resume_queue set processed_at = now()
where approval_id = ${job.approval_id}
`;
}
return jobs.length;
});
}for update of q skip locked lets several workers run without picking up the same job. The resume callback is where your framework comes in: resume a durable workflow, add a tool approval response to the conversation and call the agent again, or send a signal. Whatever it does, make it idempotent by approval ID. If one job in a batch throws, the transaction rolls back and the whole batch is retried.
Before executing an approved action, re-check the world. The refund may have been issued manually while the request waited. Approval says the action was acceptable when it was reviewed. It does not promise the action still makes sense.
Requests nobody answers need an owner too. This route declines them and queues the result for the agent:
import { sql } from "@/lib/db";
export async function GET(request: Request): Promise<Response> {
const secret = process.env.CRON_SECRET;
// Without the first check, a missing secret would accept "Bearer undefined".
if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) {
return new Response("Unauthorized", { status: 401 });
}
const expired = await sql.begin(async (tx) => {
const rows = await tx<{ id: string; run_id: string }[]>`
update approval_requests
set status = 'expired', decided_at = now()
where status = 'pending' and expires_at <= now()
returning id, run_id
`;
for (const row of rows) {
await tx`
insert into agent_resume_queue (approval_id, run_id, decision)
values (${row.id}, ${row.run_id}, 'expired')
`;
}
return rows.length;
});
return Response.json({ expired });
}The secret check follows Vercel's cron guidance, including the check for a missing secret. Comparing against `Bearer ${process.env.CRON_SECRET}` alone would accept the literal header Bearer undefined in any environment where the variable was never set.
This handler changes state on GET because that is how Vercel invokes cron jobs. It is safe only because it requires a secret and never appears in an email. Vercel also documents that cron delivery can be missed or duplicated, and that Hobby plans run cron jobs at most once a day. The sweep is idempotent, and expiry is enforced on read, so a late sweep delays the agent's notification without extending the approval window.
A reminder at the halfway point is worth adding for requests with a long window. Send it only if the request is still pending, give it its own idempotency key, and don't generate a new token: the original link still works.
When a link is not enough
Everything above treats the token as the approver's credential. Anyone who has the email can decide, which includes a colleague it was forwarded to, a shared inbox, or someone who got into the mailbox. For low-risk actions like publishing a draft or sending an internal summary, that is a reasonable trade for a one-click flow.
For moving money, deleting data, changing permissions, or anything irreversible, require the approver to be signed in as the named approver before the form is shown, and check the session again inside the Server Action. Keep the token as well, so a signed-in user still needs the specific email to reach a specific request. Add a column such as requires_sign_inand let the agent set it from the tool's risk level. Don't let the model choose it at runtime.
- Links in the email only open a page. Decisions are POSTs from that page, because mail scanners request links before people do.
- Store the exact action, and execute what was stored after approval.
- Keep a hash of a random token, compare in constant time, and return 404 for wrong tokens and unknown IDs alike.
- Record the decision with a conditional update and write the resume job in the same transaction.
- Expire by default, enforce expiry on read, and require sign-in for irreversible actions.