A headless agent is the one that most needs a human in the loop and least tolerates the usual ways of putting one there. It runs on a cron trigger, off a webhook, or as a background job that outlives any session — so there's no terminal to print Approve? [y/N] to, and no live user in an app to click a dialog. The approval has to go to the human, into a channel they already watch, and the decision has to come back over a path your server controls. In practice that means a Slack message with two buttons.
Here's the entire loop, and the one place — the signature check — where a shortcut turns your safety feature into a remote "run the dangerous thing" button.
The shape: four moves, and the decision lives in a store#
The mistake is to await the human inside the running process. Processes die — redeploys, cold starts, a 30-minute gap while your on-call finishes lunch. Park the decision in a store instead:
- Post + park — the agent hits a risky tool call, writes a
pendingrecord keyed by anapproval_id, and posts an interactive Slack message. Then it returns control; it does not sit and spin. - Receive — Slack POSTs a
block_actionspayload to your interactivity Request URL when someone clicks. - Verify + flip — your handler checks the signature, then flips the record to
approvedordenied. - Resume — the parked run wakes (poll the row, or await a pub/sub message the handler publishes) and reads the decision.
Because step 3's outcome is durable, a restart anywhere between "asked" and "clicked" costs nothing. This is the same durability lesson as pausing a terminal agent for approval — the difference is only where the human is standing.
1. Post the Approve/Deny message#
The value on each button is your approval_id — that's the string that comes back so you know which pending call was decided.
await slack.chat.postMessage({
channel: "#agent-approvals",
text: `Agent wants to refund order ${orderId} ($${amount})`, // fallback for notifications
blocks: [
{ type: "section", text: { type: "mrkdwn",
text: `*Approval needed*\nRefund order \`${orderId}\` for *$${amount}*\nRequested by \`billing-agent\`` } },
{ type: "actions", block_id: `appr_${approvalId}`, elements: [
{ type: "button", action_id: "approve", style: "primary",
text: { type: "plain_text", text: "Approve" }, value: approvalId,
confirm: { title: { type: "plain_text", text: "Issue this refund?" },
text: { type: "plain_text", text: `$${amount} back to the customer.` },
confirm: { type: "plain_text", text: "Do it" },
deny: { type: "plain_text", text: "Cancel" } } },
{ type: "button", action_id: "deny", style: "danger",
text: { type: "plain_text", text: "Deny" }, value: approvalId }
] }
]
});
The confirm object gives you a second, native "are you sure" on the destructive path for free — worth it on anything with a dollar sign.
2. Verify the request BEFORE you trust it#
Your interactivity Request URL is public. Anyone who finds it can POST payload={"actions":[{"action_id":"approve"...}]} unless you check that Slack actually sent it. Slack signs a string of the form v0:{timestamp}:{rawBody} with your app's signing secret; you recompute it and compare.
import crypto from "node:crypto";
function verifySlack(req, rawBody) {
const ts = req.headers["x-slack-request-timestamp"];
// Replay guard: refuse anything older than 5 minutes.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const base = `v0:${ts}:${rawBody}`;
const mine = "v0=" + crypto.createHmac("sha256", process.env.SLACK_SIGNING_SECRET)
.update(base).digest("hex");
const theirs = req.headers["x-slack-signature"];
// Constant-time compare — never `===` on secrets.
return mine.length === theirs.length &&
crypto.timingSafeEqual(Buffer.from(mine), Buffer.from(theirs));
}
Two non-negotiables live in that function: the five-minute timestamp window (kills captured-request replays) and the constant-time compare (kills timing attacks on the HMAC). Skip either and the gate is theater. This is exactly the un-glamorous plumbing that keeps an agent off the wrong end of the lethal trifecta.
3. Flip the record, then close the loop in Slack#
You have three seconds to acknowledge, so do the fast durable write, ack, and let the resume happen out of band.
app.post("/slack/interactions", async (req, res) => {
if (!verifySlack(req, req.rawBody)) return res.status(401).end();
const payload = JSON.parse(req.body.payload);
const action = payload.actions[0];
const approvalId = action.value;
const decision = action.action_id === "approve" ? "approved" : "denied";
await store.update(approvalId, { status: decision, by: payload.user.id });
res.status(200).end(); // ack within 3s
// Repaint the message so the buttons can't be clicked twice.
await slack.chat.update({
channel: payload.channel.id, ts: payload.message.ts,
text: `Refund ${decision} by <@${payload.user.id}>`,
blocks: [{ type: "section", text: { type: "mrkdwn",
text: `${decision === "approved" ? "✅" : "🛑"} *${decision}* by <@${payload.user.id}>` } }]
});
events.publish(`approval:${approvalId}`, decision); // wake the parked run
});
The chat.update is not cosmetic: it removes the buttons so a second click can't re-fire the decision, and it leaves an audit line in the channel showing who approved — the record you'll want when something goes wrong. For actions you cannot cleanly reverse, pair the gate with a rollback plan so a wrong approval isn't terminal.
4. Resume, and always have a default#
The parked run reads the store and continues — approved calls the tool, denied returns a clean refusal to the model. The case people forget is nobody clicks. Give every pending approval a TTL; when it expires, deny-by-default on anything irreversible, chat.update the message to "timed out", and record it. A gate that can hang forever isn't a gate, it's a new way for your agent to get stuck.
That's the whole thing: post and park, verify, flip, resume — with the decision in a store so it survives everything between the ask and the click. It's more moving parts than a terminal y/N, but it's the only shape that fits an agent running while you're asleep. If your human is actually in your product instead, the in-app approval pattern is less machinery; if they're at a terminal, PauseChain is simpler still. Match the channel to where the human already is.



