Skip to main content
Workflows

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.

See the full example on GitHub. View on GitHub
HTTP serverApp or agentasks to run a toolApproverin Slack/approvalsapprovaldecision or timeoutYour APIruns the toolsend request1Slack message with Approve and Reject2click3getStatus, send decision4run-tool step5finished event6Expires after 3 days with no answer.One per approval requestWorkflowCallReply

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

npm add @rivet-dev/workflows rivetkit 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:

  • 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:

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

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

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:

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.

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