Human Approval for Background AI Workers

Background AI workers run on a schedule or a queue, retry themselves on failure, and have nobody watching — which turns "just add a confirmation step" into a genuinely hard problem worth solving properly.


The background-worker version of the problem

A chat agent has a person on the other end of the conversation. A background worker — a cron job, a BullMQ/Sidekiq/Celery consumer, a scheduled Lambda — doesn't. It wakes up, does its work, and if that work ends in "send the digest email" or "post the summary to the team channel," there's no natural point where a human would normally intervene. Two things make this worse than the webhook case:

Both point at the same fix: the approval has to be a state the job checks before acting, not a notification the job hopes gets read, and the state has to be safe to check again after a crash.


Example: a nightly digest worker

Say a worker runs every night, summarizes the day's support tickets, and emails the summary to a distribution list. Here's a TypeScript BullMQ-style job that gates the send:

typescript
import { Worker } from "bullmq";

const IMPRI_BASE = process.env.IMPRI_BASE_URL ?? "https://api.impri.dev";
const IMPRI_KEY = process.env.IMPRI_API_KEY!;

async function pushAction(title: string, body: string) {
  const res = await fetch(`${IMPRI_BASE}/v1/actions`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${IMPRI_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      kind: "email.send",
      title,
      preview: { format: "markdown", body },
      expires_in: 43200, // 12h — send it before the next day's digest replaces it
      editable: ["preview.body"],
      idempotent: false,
      undo: "No undo — email already delivered if sent in error; follow up with a correction.",
    }),
  });
  return res.json() as Promise<{ id: string; status: string }>;
}

async function awaitDecision(actionId: string) {
  while (true) {
    const res = await fetch(`${IMPRI_BASE}/v1/actions/${actionId}`, {
      headers: { Authorization: `Bearer ${IMPRI_KEY}` },
    });
    const data = await res.json();
    if (data.status !== "pending") return data;
    await new Promise((r) => setTimeout(r, 15_000));
  }
}

new Worker("nightly-digest", async (job) => {
  const summary = await summarizeTickets(job.data.date); // your own logic
  const action = await pushAction(`Nightly digest — ${job.data.date}`, summary);

  const decision = await awaitDecision(action.id);
  if (decision.status !== "approved") {
    return; // rejected or expired — job completes successfully, nothing sent
  }

  await sendEmail(decision.decision.final_preview.body);
  await fetch(`${IMPRI_BASE}/v1/actions/${action.id}/result`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${IMPRI_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ status: "executed" }),
  });
});

Why this survives a queue retry

If the process crashes right after sendEmail() but before the /result call completes, BullMQ will retry the job. On retry, job.data.date is the same, so summarizeTickets() re-runs and pushAction() creates a new action — the old one is left dangling as approved-but-unreported, and the retry pushes a fresh approval request rather than silently re-sending. That's the honest behavior given the API surface here: Impri does not track "did the caller actually execute this," only "was it approved" and whatever you report back via /result. If you need stronger de-duplication across retries (e.g. a job-id keyed skip), that has to live in your own worker state — Impri's idempotent field is a signal shown to the human reviewer, not an automatic dedupe mechanism.

Setting idempotent: false matters here specifically because queue systems retry: it puts a "retrying may duplicate this action" warning on the inbox card, which is exactly the risk a background worker's approver needs to know about that a chat-agent approver usually doesn't.


Where the human actually sees this

Nobody is staring at a terminal for a job that runs at 2am. Route the notification to something that reaches a phone: Telegram, Slack, or Discord approval channels, or web push. The expires_in of 12 hours in the example above is deliberately long enough to survive a night's sleep but short enough that an approval given the next afternoon doesn't send a day-old digest.


A quick comparison

Trigger type Who's watching Typical expires_in Biggest risk if ungated
Chat agent The user, live Minutes to hours Bad draft sent immediately
Webhook-triggered agent Nobody, but the event is fresh Minutes to a few hours Acting on a fast-changing event
Background/queue worker Nobody, and retries happen Hours Duplicate side effects from retries

Next step

Read how to add human approval to an AI agent for the base three-call pattern, then quickstart to get a key and pick a notification channel that reaches you outside working hours.