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:

  1. Post + park — the agent hits a risky tool call, writes a pending record keyed by an approval_id, and posts an interactive Slack message. Then it returns control; it does not sit and spin.
  2. Receive — Slack POSTs a block_actions payload to your interactivity Request URL when someone clicks.
  3. Verify + flip — your handler checks the signature, then flips the record to approved or denied.
  4. 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.