# Human-in-the-Loop Approvals

Your app, or an agent tool, calls send on a new approval workflow with the request. The workflow records the request and posts a Slack message with Approve and Reject buttons to the approver. It then waits on its decision queue with a three-day timeout, without holding a server. When the approver clicks a button, Slack posts to the /approvals route of your HTTP server, which calls getStatus to check the request and then send with the decision. On approve the workflow runs the tool against your API in the run-tool step. Finally it broadcasts a finished event to the requester. The numbers show the order of the calls. There is one workflow per approval request.

What we'll build:

- An `approval` workflow 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

```sh
npm add @rivet-dev/workflows rivetkit@next hono @hono/node-server
```

### Set up Slack

<a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer">Create a Slack app</a> and give its bot the `chat:write` scope. Install it to your workspace. Then set:

```sh
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.ts:

```ts
// 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:

approval.ts:

```ts
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,
	},
});
```

slack.ts:

```ts
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`, so `getStatus` can show what is being asked at any time, even while the workflow waits.
- Each button's `value` is the request id. That is all `/approvals` needs to find the right workflow.
- Without `SLACK_BOT_TOKEN`, `postMessage` logs 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 `/approvals` does below.

### Wait for a decision

The workflow then waits for a `decision` message or for the timeout, whichever comes first:

approval.ts:

```ts
// 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,
});
```

- `nextBatch` with a `timeout` returns 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:

approval.ts:

```ts
// 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.
- `tryStep` retries the tool with backoff. If it keeps failing, it returns the error as data instead of failing the workflow, so the outcome is `failed` and the requester still hears about it.
- `broadcast` sends the outcome to connected clients, which is how a waiting agent tool gets its answer. The `notify-requester` step 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:

server.ts:

```ts
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) });
```

- `verifySlackRequest` in `slack.ts` checks Slack's signature and rejects requests more than 5 minutes old.
- `getStatus` reads 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:

agent-tool.ts:

```ts
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}.`;
	},
};
```

- `requestApproval` starts a workflow with a new id, sends the request, and waits for the `finished` event.
- 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-tool` step, not in the agent. Even a confused agent cannot refund without a person saying yes.

> **NOTE:** 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:

```sh
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`.

> **NOTE:** If you haven't deployed yet, run `ngrok http 3000` and use the ngrok URL as `https:// `.

Then call the tool the way an agent would:

client.ts:

```ts
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);
```

```sh
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:

decide.ts:

```ts
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}`);
```

```sh
npx tsx decide.ts <request-id> approve
```

## Next steps

- [Multi-Step AI Workflows](/guides/ai-workflows): add an approval before an AI job publishes its result.
- [Human in the Loop for agents](/agents/docs/human-in-the-loop): ask the user before an agent acts.
- [Timers & Concurrency](/workflows/docs/timers-and-concurrency#race): use `race` when the wait has more than two ways to end.

## Related docs

- [Queues & Messaging](/workflows/docs/queues#human-approval-gate): the basic approval gate.
- [Steps](/workflows/docs/steps#handling-terminal-failures-as-data): how `tryStep` returns failures as data.
- [Timers & Concurrency](/workflows/docs/timers-and-concurrency): durable sleeps and timeouts.
- [Patterns](/workflows/docs/patterns): storing progress in state and broadcasting it.
