Human-in-the-Loop Approvals
Pause a workflow or an agent's tool call until a person approves or rejects it, even if the answer takes days.
What we’ll build:
- An
approvalworkflow per request that records which tool should run, with which arguments, and who must answer. - A Slack message that shows the request to the approver with Approve and Reject buttons.
- A durable wait on the decision queue that can last for days without a running server.
- A timeout that expires the request and tells the requester when no one answers.
- The same gate wrapped around an agent’s tool, so the tool runs only after a person approves it.
Install
npm add @rivet-dev/workflows rivetkit@3.0.0-preview.1 hono @hono/node-server
Set up Slack
Create a Slack app and give its bot the chat:write scope. Install it to your workspace. Then set:
SLACK_BOT_TOKEN=xoxb-...
SLACK_SIGNING_SECRET=...
APPROVER_SLACK_ID=U...
AGENT_SLACK_CHANNEL=C...
ORDERS_API_URL=https://api.example.com
APPROVER_SLACK_ID is the Slack user who answers requests. AGENT_SLACK_CHANNEL is where the requester hears the outcome. You turn on button clicks in the last step, once the server is running.
Define the tools
The tools that need approval live in one place. The workflow runs them, never the caller:
// Tools that change something important. The approval workflow runs them only after a person approves.
// Each tool gets the approval request id, so your API can ignore a repeated call for the same request.
const tools = {
refund_order: async (
args: { orderId: string; amountCents: number },
requestId: string,
) => {
const apiUrl = process.env.ORDERS_API_URL ?? "https://api.example.com";
const res = await fetch(`${apiUrl}/orders/${args.orderId}/refunds`, {
method: "POST",
headers: {
"content-type": "application/json",
"idempotency-key": requestId,
},
body: JSON.stringify({ amountCents: args.amountCents }),
});
if (!res.ok) throw new Error(`refund failed with status ${res.status}`);
return (await res.json()) as { refundId: string };
},
};
type Tools = typeof tools;
// A tool name with matching arguments, such as { tool: "refund_order", args: { orderId, amountCents } }.
export type ToolCall = {
[K in keyof Tools]: { tool: K; args: Parameters<Tools[K]>[0] };
}[keyof Tools];
export async function runTool(call: ToolCall, requestId: string) {
return await tools[call.tool](call.args, requestId);
}
- A request names a tool and its arguments as data, such as
{ tool: "refund_order", args: { ... } }. Code cannot be put on a queue, so the workflow looks the tool up by name when it runs it. - Each call sends the request id as an idempotency key. If the call is retried, your API can return the first result instead of refunding twice.
Create the approval workflow
Each request is its own approval workflow, keyed by a request id. It waits for the request, records it, and asks the approver:
import { event, queue, workflow } from "@rivet-dev/workflows";
import { approvalBlocks, postMessage } from "./slack";
import { runTool, type ToolCall } from "./tools";
// What is being asked, and who must answer. `requestedBy` and `approver` are Slack user or channel ids.
export type ApprovalRequest = ToolCall & {
summary: string;
requestedBy: string;
approver: string;
};
export type Decision = { approved: boolean; decidedBy: string };
export type Outcome = {
status: "approved" | "rejected" | "expired" | "failed";
decidedBy: string | null;
result: unknown;
error: string | null;
};
// How long the approver has to answer before the request expires.
const DECISION_TIMEOUT_MS = 3 * 24 * 60 * 60 * 1000;
// One approval workflow per request, keyed by the request id.
export const approval = workflow({
state: {
request: null as ApprovalRequest | null,
status: "new" as "new" | "pending" | Outcome["status"],
outcome: null as Outcome | null,
},
queues: {
request: queue<ApprovalRequest>(),
decision: queue<Decision>(),
},
events: {
finished: event<Outcome>(),
},
run: async (ctx) => {
const requestId = ctx.key[0];
const { body: request } = await ctx.queue.next("wait-request", {
names: ["request"],
});
await ctx.step("record-request", async (step) => {
step.state.request = request;
step.state.status = "pending";
});
await ctx.step("notify-approver", async () => {
const text = `Run \`${request.tool}\`? ${request.summary}`;
await postMessage(
request.approver,
`Approval needed for request ${requestId}`,
approvalBlocks(requestId, text),
);
});
// Wait for a decision message, or give up after the timeout. Both are durable.
const [decision] = await ctx.queue.nextBatch("wait-decision", {
names: ["decision"],
timeout: DECISION_TIMEOUT_MS,
});
// No decision means the timeout fired first.
let outcome: Outcome = {
status: decision ? "rejected" : "expired",
decidedBy: decision?.body.decidedBy ?? null,
result: null,
error: null,
};
if (decision?.body.approved) {
// The tool runs only here, after a person approved it.
const run = await ctx.tryStep({
name: "run-tool",
maxRetries: 5,
retryBackoffBase: 1000,
run: async () => await runTool(request, requestId),
});
outcome = run.ok
? { ...outcome, status: "approved", result: run.value }
: { ...outcome, status: "failed", error: run.failure.error.message };
}
await ctx.step("record-outcome", async (step) => {
step.state.status = outcome.status;
step.state.outcome = outcome;
step.broadcast("finished", outcome);
});
await ctx.step("notify-requester", async () => {
await postMessage(
request.requestedBy,
`Your request to run \`${request.tool}\` ended as ${outcome.status}.`,
);
});
},
actions: {
getStatus: (c) => c.state,
},
});
import { createHmac, timingSafeEqual } from "node:crypto";
// Posts a message to a Slack user or channel id. Without SLACK_BOT_TOKEN it only logs the message, for local testing.
export async function postMessage(
channel: string,
text: string,
blocks?: unknown[],
) {
const token = process.env.SLACK_BOT_TOKEN;
if (!token) {
console.log(`slack message to ${channel}: ${text}`);
return;
}
const res = await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify({ channel, text, blocks }),
});
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) throw new Error(`slack chat.postMessage failed: ${body.error}`);
}
// A message with Approve and Reject buttons. Each button carries the request id back to /approvals.
export function approvalBlocks(requestId: string, text: string) {
const button = (actionId: string, label: string, style: string) => ({
type: "button",
action_id: actionId,
style,
text: { type: "plain_text", text: label },
value: requestId,
});
return [
{ type: "section", text: { type: "mrkdwn", text } },
{
type: "actions",
elements: [
button("approve", "Approve", "primary"),
button("reject", "Reject", "danger"),
],
},
];
}
// The parts of a Slack button click that /approvals reads.
export type SlackButtonClick = {
user: { id: string };
actions: Array<{ action_id: string; value: string }>;
response_url: string;
};
// Replaces or adds to the message that held the clicked button.
export async function respond(
responseUrl: string,
body: Record<string, unknown>,
) {
await fetch(responseUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// Slack signs "v0:<timestamp>:<raw body>" with your app's signing secret.
export function verifySlackRequest(
rawBody: string,
timestamp: string | undefined,
signature: string | undefined,
) {
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 60 * 5) return false;
const expected = `v0=${createHmac(
"sha256",
process.env.SLACK_SIGNING_SECRET ?? "",
)
.update(`v0:${timestamp}:${rawBody}`)
.digest("hex")}`;
return (
expected.length === signature.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
);
}
- The request is stored in
state, sogetStatuscan show what is being asked at any time, even while the workflow waits. - Each button’s
valueis the request id. That is all/approvalsneeds to find the right workflow. - Without
SLACK_BOT_TOKEN,postMessagelogs the message instead of posting it, so you can try the flow before Slack is set up. - To approve by email instead, send two links to a route of your own. The route then sends the decision the same way
/approvalsdoes below.
Wait for a decision
The workflow then waits for a decision message or for the timeout, whichever comes first:
// Wait for a decision message, or give up after the timeout. Both are durable.
const [decision] = await ctx.queue.nextBatch("wait-decision", {
names: ["decision"],
timeout: DECISION_TIMEOUT_MS,
});
nextBatchwith atimeoutreturns an empty list when the time runs out. With a message, it returns that message.- The deadline is recorded in the workflow’s history. While it waits, the Rivet Actor can sleep and no process is held. A decision message or the deadline wakes it up, even after a restart or a deploy.
names: ["decision"]reads only decisions, so nothing else on the queue can be mistaken for an answer.
Act on the decision
On approve, the workflow runs the tool. Every path records the outcome and tells the requester:
// No decision means the timeout fired first.
let outcome: Outcome = {
status: decision ? "rejected" : "expired",
decidedBy: decision?.body.decidedBy ?? null,
result: null,
error: null,
};
if (decision?.body.approved) {
// The tool runs only here, after a person approved it.
const run = await ctx.tryStep({
name: "run-tool",
maxRetries: 5,
retryBackoffBase: 1000,
run: async () => await runTool(request, requestId),
});
outcome = run.ok
? { ...outcome, status: "approved", result: run.value }
: { ...outcome, status: "failed", error: run.failure.error.message };
}
await ctx.step("record-outcome", async (step) => {
step.state.status = outcome.status;
step.state.outcome = outcome;
step.broadcast("finished", outcome);
});
await ctx.step("notify-requester", async () => {
await postMessage(
request.requestedBy,
`Your request to run \`${request.tool}\` ended as ${outcome.status}.`,
);
});
- No decision means the request expired. Expired and rejected requests skip the tool entirely.
tryStepretries the tool with backoff. If it keeps failing, it returns the error as data instead of failing the workflow, so the outcome isfailedand the requester still hears about it.broadcastsends the outcome to connected clients, which is how a waiting agent tool gets its answer. Thenotify-requesterstep also posts it in Slack.
Handle button clicks
Slack posts every button click to /approvals. The route checks the request, then hands the decision to the workflow:
import { serve } from "@hono/node-server";
import { setup } from "@rivet-dev/workflows";
import { Hono } from "hono";
import { createClient } from "rivetkit/client";
import { approval } from "./approval";
import { respond, type SlackButtonClick, verifySlackRequest } from "./slack";
export const registry = setup({ use: { approval } });
const client = createClient<typeof registry>();
registry.start();
const app = new Hono();
// Slack calls this when the approver clicks Approve or Reject.
app.post("/approvals", async (c) => {
const rawBody = await c.req.text();
const signed = verifySlackRequest(
rawBody,
c.req.header("x-slack-request-timestamp"),
c.req.header("x-slack-signature"),
);
if (!signed) return c.text("invalid signature", 401);
const click = JSON.parse(
new URLSearchParams(rawBody).get("payload") ?? "{}",
) as SlackButtonClick;
const [button] = click.actions;
const handle = client.approval.get([button.value]);
// The pending request is readable while the workflow waits.
const { status, request } = await handle.getStatus();
if (status !== "pending") {
await respond(click.response_url, {
response_type: "ephemeral",
replace_original: false,
text: `This request is already ${status}.`,
});
return c.body(null, 200);
}
if (click.user.id !== request?.approver) {
await respond(click.response_url, {
response_type: "ephemeral",
replace_original: false,
text: "Only the assigned approver can answer.",
});
return c.body(null, 200);
}
const approved = button.action_id === "approve";
await handle.send("decision", { approved, decidedBy: click.user.id });
// Replace the buttons so nobody clicks twice.
await respond(click.response_url, {
replace_original: true,
text: `${approved ? "Approved" : "Rejected"} by <@${click.user.id}>.`,
});
return c.body(null, 200);
});
serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) });
verifySlackRequestinslack.tschecks Slack’s signature and rejects requests more than 5 minutes old.getStatusreads the waiting workflow directly. A late click on an expired or already decided request gets a short reply instead of a second decision.- Only the assigned approver can answer. Anyone else who clicks gets an ephemeral reply that only they can see.
- The route replaces the original message through Slack’s
response_url, so the buttons disappear once someone has answered.
Gate an agent's tool
An agent tool can ask for approval the same way. Its execute waits until a person answers:
import { randomUUID } from "node:crypto";
import { createClient } from "rivetkit/client";
import type { ApprovalRequest, Outcome } from "./approval";
import type { registry } from "./server";
const client = createClient<typeof registry>();
// Starts an approval workflow for a new request and resolves when it finishes.
export async function requestApproval(
request: ApprovalRequest,
): Promise<Outcome> {
const handle = client.approval.getOrCreate([randomUUID()]);
const conn = handle.connect();
try {
const finished = new Promise<Outcome>((resolve) => {
conn.once("finished", (outcome) => resolve(outcome as Outcome));
});
await handle.send("request", request);
return await finished;
} finally {
await conn.dispose();
}
}
// An agent tool that asks a person before it refunds an order.
export const refundOrderTool = {
name: "refund_order",
description:
"Refund part or all of an order. A person approves every refund before it happens.",
parameters: {
type: "object",
properties: {
orderId: { type: "string" },
amountCents: { type: "number" },
reason: { type: "string" },
},
required: ["orderId", "amountCents", "reason"],
},
execute: async (args: {
orderId: string;
amountCents: number;
reason: string;
}) => {
const outcome = await requestApproval({
tool: "refund_order",
args: { orderId: args.orderId, amountCents: args.amountCents },
summary: `Refund $${(args.amountCents / 100).toFixed(2)} on order ${args.orderId}. Reason: ${args.reason}`,
requestedBy: process.env.AGENT_SLACK_CHANNEL ?? "C0AGENT",
approver: process.env.APPROVER_SLACK_ID ?? "U0APPROVER",
});
if (outcome.status === "approved")
return `Refund done: ${JSON.stringify(outcome.result)}`;
return `No refund was made. The request ended as ${outcome.status}.`;
},
};
requestApprovalstarts a workflow with a new id, sends the request, and waits for thefinishedevent.- The tool shape, with a name, a description, JSON Schema parameters, and
execute, works with most agent frameworks. Adapt it to yours. - The refund itself happens in the workflow’s
run-toolstep, not in the agent. Even a confused agent cannot refund without a person saying yes.
The tool waits only as long as its process runs. The request, the decision, and the refund live in the workflow, so they continue even if the agent restarts. Keep the request id and call getStatus to pick up the outcome later.
Try it
Start the server:
npx tsx server.ts
In your Slack app settings, open Interactivity & Shortcuts, turn it on, and set the Request URL to https://<your-server>/approvals.
If you haven’t deployed yet, run ngrok http 3000 and use the ngrok URL as https://<your-server>.
Then call the tool the way an agent would:
import { refundOrderTool } from "./agent-tool";
// Call the tool the way an agent would. This waits until someone approves or rejects the refund.
const answer = await refundOrderTool.execute({
orderId: "order-1042",
amountCents: 2500,
reason: "The package arrived damaged.",
});
console.log(answer);
npx tsx client.ts
The approver gets a Slack message. When they click Approve, the refund runs and client.ts prints the result.
To test without Slack, leave SLACK_BOT_TOKEN unset. The server logs the request id. Answer it from another terminal:
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
// Answer a request without Slack: npx tsx decide.ts <request-id> approve|reject
const [requestId, answer] = process.argv.slice(2);
const client = createClient<typeof registry>();
const handle = client.approval.get([requestId]);
const { request } = await handle.getStatus();
await handle.send("decision", {
approved: answer === "approve",
decidedBy: request?.approver ?? "unknown",
});
console.log(`sent ${answer} for request ${requestId}`);
npx tsx decide.ts <request-id> approve
Next steps
- Multi-Step AI Workflows: add an approval before an AI job publishes its result.
- Human in the Loop for agents: ask the user before an agent acts.
- Timers & Concurrency: use
racewhen the wait has more than two ways to end.
Related docs
- Queues & Messaging: the basic approval gate.
- Steps: how
tryStepreturns failures as data. - Timers & Concurrency: durable sleeps and timeouts.
- Patterns: storing progress in state and broadcasting it.