Skip to content

Building human-in-the-loop agents for community moderation with durable workflows

Learn how to build AI agents that escalate decisions to Slack, pause and resume runs reliably, and stream progress using Vercel Workflows.

21 min read
Last updated May 29, 2026

A human-in-the-loop pattern is one in which the AI agent requires human input before continuing the task. For example, a support agent may require human approval before proceeding with a $1000 refund to a customer. Community moderation is another use case for the this pattern because while most posts can be resolved automatically by AI agents, borderline cases require a person before the system takes action.

One challenge with building human-in-the-loop agents is that, while an AI model can respond while the request is still open, a moderator might reply in 5 seconds, 2 hours, or after the request has ended. That means you need to persist and synchronize state and manage retries, which adds complexity. Durable workflows solve this by letting the agent pause, persist its state, and resume when the human responds, with retries for each step separately.

In this guide, you’ll learn to implement this pattern reliably with durable workflows. Continuing from the Building community moderation with agents using AI SDK guide, you’ll add a durable agent using Vercel Workflows and escalate borderline cases to Slack for human review. You’ll also learn to stream workflow progress to the frontend for a real-time experience.

Before you begin, make sure you have:

The human-in-the-loop pattern combines a durable agent built with Workflow SDK and Slack approvals handled through Chat SDK. The user request follows this path:

  1. A new post starts a workflow run.
  2. The agent gathers context and triages the action.
  3. Clear decisions are applied immediately.
  4. Borderline decisions create a hook token and post a Slack message for review.
  5. The workflow pauses until the moderator clicks or the timeout fires.
  6. The Slack handler resumes the hook with the selected action.
  7. The workflow applies the final decision and updates the UI and the Slack message.

To install the packages, run the following command:

pnpm add workflow @workflow/next @workflow/ai

To use the "use workflow" and "use step" directives, wrap the Next.js config with the Workflow SDK. In next.config.ts, update the configuration, as follows:

import type { NextConfig } from "next";
import { withWorkflow } from "workflow/next";
const nextConfig: NextConfig = {};
export default withWorkflow(nextConfig);

Start the app by running:

pnpm dev

The previous guide used the AI SDK directly, but it now needs to wait for a human, survive process restarts, replay completed tool calls, and stream progress from the same run. For this, you’ll use DurableAgent from the Workflow SDK.

First, mark the existing side-effect helpers as workflow steps. Add "use step" as the first statement inside these functions:

lib/agent-tools.ts
export async function getAuthorHistory(args: { authorId: string }) {
"use step";
// existing implementation
}
export async function findSimilarReports(args: { text: string }) {
"use step";
// existing implementation
}
export async function lookupPolicy(args: { category: string }) {
"use step";
// existing implementation
}
lib/actions.ts
export async function sendWarningToCommunityUser(
userId: string,
message: string,
): Promise<void> {
"use step";
// existing implementation
}
export async function executeAction(
postId: string,
action: ActionId,
draftedWarning: string | undefined,
actorId: string,
): Promise<void> {
"use step";
// existing implementation
}

Then replace the AI SDK ToolLoopAgent in lib/triage-agent.ts with a DurableAgent. Replace the imports at the top of the file with:

import { DurableAgent } from "@workflow/ai/agent";
import { z } from "zod";
import { findSimilarReports, getAuthorHistory, lookupPolicy } from "./agent-tools";
import { TriageSchema } from "./types";
import type { TriageOutput } from "./types";

Then replace runTriage() with createTriageAgent():

lib/triage-agent.ts
export function createTriageAgent() {
return new DurableAgent({
model: TRIAGE_MODEL,
instructions: TRIAGE_INSTRUCTIONS,
tools: {
getAuthorHistory: {
description:
"Look up the author's moderation history: account age, prior strikes, recent actions, overall tone.",
inputSchema: z.object({
authorId: z.string().describe("Internal user ID of the post's author."),
}),
execute: getAuthorHistory,
},
findSimilarReports: {
description:
"Find up to 3 prior moderation cases similar to the post, with their outcomes.",
inputSchema: z.object({
text: z.string().describe("The post body to find similar cases for."),
}),
execute: findSimilarReports,
},
lookupPolicy: {
description: "Look up the community-guidelines section for a category.",
inputSchema: z.object({
category: z
.string()
.describe(
"Category like 'harassment', 'spam', 'hate_speech', 'self_harm', 'off_topic'.",
),
}),
execute: lookupPolicy,
},
submitTriage: {
description:
"Submit your final triage decision. This is your last action, call it exactly once.",
inputSchema: TriageSchema,
execute: async (triage: TriageOutput) => triage,
},
},
});
}

The interface stays the same for the model, but the runtime behavior changes. Tool calls can now be replayed from workflow state, and the workflow can stream chunks from the durable run while it waits for human review.

With the workflow steps in place, it is time to assemble them into a workflow. Create workflows/triage.ts :

import { getWritable } from "workflow";
import { hasToolCall, type UIMessageChunk } from "ai";
import { executeAction } from "@/lib/actions";
import { appendAudit, getPost, setPostStatus } from "@/lib/db";
import { createTriageAgent } from "@/lib/triage-agent";
import type { Post, TriageOutput } from "@/lib/types";
workflows/triage.ts
export async function triagePostWorkflow(postId: string): Promise<void> {
"use workflow";
await markUnderReview(postId);
await pingStatusChange(postId);
const post = await loadPost(postId);
if (!post) return;
const triage = await runTriageAgent(post);
await applyTriageDecision(postId, triage);
}

Inside the workflow, runTriageAgent() creates a triage agent that runs a tool-calling loop:

workflows/triage.ts
async function runTriageAgent(post: Post): Promise<TriageOutput | undefined> {
const triageAgent = createTriageAgent();
const result = await triageAgent.stream({
messages: [
{
role: "user",
content: [
{
type: "text",
text:
`Triage this forum post.\\n\\n` +
`Post ID: ${post.id}\\n` +
`Author ID: ${post.authorId}\\n` +
`Posted at: ${post.createdAt}\\n` +
`Body:\\n"""\\n${post.body}\\n"""\\n`,
},
],
},
],
writable: getWritable<UIMessageChunk>(),
// Keep the workflow's writable open after the agent finishes so the
// later workflow steps can still emit chunks to the same stream the client
// is subscribed to.
preventClose: true,
stopWhen: hasToolCall("submitTriage"),
maxSteps: 6,
prepareStep: ({ stepNumber }) => {
// On the last allowed step, force the model to call submitTriage so the
// workflow always has a routing decision to act on.
if (stepNumber >= 5) {
return { toolChoice: { type: "tool", toolName: "submitTriage" } };
}
return {};
},
});
// submitTriage may land in any step's tool calls. Scan all steps so we don't
// miss it if the model emits a trailing text turn after submitting.
const submitCall = result.steps
.flatMap((s) => s.toolCalls)
.find((c) => c.toolName === "submitTriage");
return submitCall?.input as TriageOutput | undefined;
}

Add applyTriageDecision() to execute the triaged decision:

workflows/triage.ts
async function applyTriageDecision(
postId: string,
triage: TriageOutput | undefined,
): Promise<void> {
if (!triage) {
await setUnderReviewToLive(
postId,
"agent did not call submitTriage, workflow could not route post",
);
await pingStatusChange(postId);
} else {
await recordClassification(postId, triage);
await pingStatusChange(postId);
if (triage.decision === "safe") {
await setStatusStep(postId, "live");
await pingStatusChange(postId);
} else if (triage.decision === "auto_hide") {
await executeAction(postId, "hide", undefined, "agent");
await pingStatusChange(postId);
} else if (triage.decision === "auto_warn") {
const warning = triage.draftedWarning?.trim();
if (warning) {
await executeAction(postId, "hide_and_warn", warning, "agent");
} else {
await executeAction(postId, "hide", undefined, "agent");
}
await pingStatusChange(postId);
} else {
await keepUnderReviewForHuman(postId, triage);
await pingStatusChange(postId);
}
}
}

Add keepUnderReviewForHuman() to record the escalation while the post stays under review:

workflows/triage.ts
async function keepUnderReviewForHuman(
postId: string,
triage: TriageOutput,
): Promise<void> {
"use step";
await appendAudit({
postId,
action: "escalated",
actorId: "agent",
note: triage.humanRequest ?? "needs moderator review",
});
}

Add the workflow step helpers that read and update the post state:

workflows/triage.ts
async function loadPost(postId: string) {
"use step";
return getPost(postId) ?? null;
}
async function markUnderReview(postId: string) {
"use step";
await setPostStatus(postId, "under_review");
}
async function setUnderReviewToLive(postId: string, note: string) {
"use step";
await setPostStatus(postId, "live");
await appendAudit({
postId,
action: "auto_classified",
actorId: "agent",
note,
});
}
async function setStatusStep(postId: string, status: "live") {
"use step";
await setPostStatus(postId, status);
await appendAudit({
postId,
action: "auto_classified",
actorId: "agent",
note: "marked safe",
});
}
async function recordClassification(postId: string, triage: TriageOutput) {
"use step";
await appendAudit({
postId,
action: "auto_classified",
actorId: "agent",
note:
`decision=${triage.decision}; ` +
`category=${triage.category}; ` +
`severity=${triage.severity}; ` +
`confidence=${triage.confidence.toFixed(2)}; ` +
`reasoning=${triage.reasoning}`,
});
}

Finally, emit a custom stream chunk whenever the workflow writes state that the Server Component renders, such as post status or audit entries.

workflows/triage.ts
// Emit a custom UIMessageChunk on the workflow's writable so the client can
// react to persisted state changes without polling. AgentStream listens for the
// `data-status-change` type and calls `router.refresh()`.
async function pingStatusChange(postId: string) {
"use step";
const writable = getWritable<UIMessageChunk>();
const writer = writable.getWriter();
try {
await writer.write({
type: "data-status-change",
id: postId,
data: { ts: Date.now() },
});
} finally {
writer.releaseLock();
}
}

To start the workflow when a user submits a post, you need to use Next.js Server Actions. Create app/actions.ts:

"use server";
import { revalidatePath } from "next/cache";
import { start } from "workflow/api";
import { clearAllPosts, createPost, ensureUser, setPostRunId } from "@/lib/db";
import { triagePostWorkflow } from "@/workflows/triage";
export async function submitPost(formData: FormData): Promise<void> {
const authorId = String(formData.get("authorId") ?? "").trim();
const body = String(formData.get("body") ?? "").trim();
if (!authorId || !body) return;
await ensureUser(authorId);
const post = await createPost({ authorId, body });
const run = await start(triagePostWorkflow, [post.id]);
await setPostRunId(post.id, run.runId);
revalidatePath("/");
}
export async function clearForum(): Promise<void> {
await clearAllPosts();
revalidatePath("/");
}

The order in submitPost() matters: first persist the post, then start the workflow, then store run.runId on the post. The stream route uses that run ID to subscribe to workflow progress.

To connect the browser to the same workflow run, use the stored runId and stream the latest status back to the UI. Create app/api/posts/[id]/stream/route.ts:

import { consumeStream, createUIMessageStreamResponse } from "ai";
import { getRun } from "workflow/api";
import { getPost } from "@/lib/db";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const post = await getPost(id);
if (!post?.runId) {
return new Response("post has no active run", { status: 404 });
}
const run = getRun(post.runId);
return createUIMessageStreamResponse({
stream: run.getReadable(),
consumeSseStream: async ({ stream }) => {
try {
await consumeStream({ stream });
} catch (error) {
if (!isAbortLikeError(error)) {
console.error("Failed to consume post stream", error);
}
}
},
});
}
function isAbortLikeError(error: unknown): boolean {
if (error instanceof DOMException) {
return error.name === "AbortError" || error.name === "ResponseAborted";
}
return (
typeof error === "object" &&
error !== null &&
"name" in error &&
(error.name === "AbortError" || error.name === "ResponseAborted")
);
}

run.getReadable() replays chunks already written by the workflow and tails new chunks. If someone opens the page while a post is under review, the browser can render the agent's tool calls so far and keep listening for new ones.

Create a client component to connect to the stream. Create app/agent-stream.tsx:

"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { UIMessageChunk } from "ai";
type ToolCall = {
toolName: string;
status: "calling" | "done" | "error";
};
const TOOL_LABELS: Record<string, string> = {
getAuthorHistory: "Pulling author history",
findSimilarReports: "Searching prior cases",
lookupPolicy: "Checking guidelines",
submitTriage: "Preparing recommendation",
};

The component subscribes on mount, updates local tool-call state, and refreshes the Server Component when the workflow emits the status-change chunk.

app/agent-stream.tsx
export function AgentStream({ postId }: { postId: string }) {
const router = useRouter();
const [calls, setCalls] = useState<Record<string, ToolCall>>({});
const [order, setOrder] = useState<string[]>([]);
const [done, setDone] = useState(false);
useEffect(() => {
const controller = new AbortController();
readWorkflowStream({
postId,
signal: controller.signal,
onChunk: (chunk) =>
applyChunk(chunk, {
setCalls,
setOrder,
setDone,
refresh: router.refresh,
}),
}).catch((err: unknown) => {
if ((err as { name?: string })?.name !== "AbortError") {
console.error("[agent-stream] failed", err);
}
});
return () => {
controller.abort();
};
}, [postId, router]);
const label = done ? "Waiting for moderator" : "Agent running";
return (
<aside className="mt-4 rounded-md border border-blue-200 bg-blue-50 p-4">
<div className="text-xs font-medium uppercase text-blue-700">
Agent {label}
</div>
<ToolCallList calls={calls} order={order} />
</aside>
);
}

The stream reader converts the response body into UIMessageChunks.

app/agent-stream.tsx
async function readWorkflowStream(args: {
postId: string;
signal: AbortSignal;
onChunk: (chunk: UIMessageChunk) => void;
}): Promise<void> {
const res = await fetch(`/api/posts/${args.postId}/stream`, {
signal: args.signal,
});
if (!res.ok || !res.body) return;
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
let sep = buffer.indexOf("\\n\\n");
while (sep !== -1) {
const event = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
for (const line of event.split("\\n")) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6);
if (data === "[DONE]") continue;
try {
args.onChunk(JSON.parse(data) as UIMessageChunk);
} catch {
// Ignore malformed events. Any partial line is completed on the
// next read because the workflow stream is append-only.
}
}
sep = buffer.indexOf("\\n\\n");
}
}
}

The chunk handler maps workflow stream events to local UI state.

app/agent-stream.tsx
function applyChunk(
chunk: UIMessageChunk,
state: {
setCalls: Dispatch<SetStateAction<Record<string, ToolCall>>>;
setOrder: Dispatch<SetStateAction<string[]>>;
setDone: Dispatch<SetStateAction<boolean>>;
refresh: () => void;
},
): void {
if (chunk.type === "tool-input-start") {
const id = chunk.toolCallId;
state.setCalls((prev) => ({
...prev,
[id]: { toolName: chunk.toolName, status: "calling" },
}));
state.setOrder((prev) => (prev.includes(id) ? prev : [...prev, id]));
} else if (chunk.type === "tool-output-available") {
markCall(chunk.toolCallId, "done", state.setCalls);
} else if (chunk.type === "tool-output-error") {
markCall(chunk.toolCallId, "error", state.setCalls);
} else if (chunk.type === "finish") {
state.setDone(true);
} else if (chunk.type === "data-status-change") {
state.refresh();
}
}

Finish the component file with the following rendering helpers:

app/agent-stream.tsx
function markCall(
id: string,
status: ToolCall["status"],
setCalls: Dispatch<SetStateAction<Record<string, ToolCall>>>,
): void {
setCalls((prev) =>
prev[id] ? { ...prev, [id]: { ...prev[id], status } } : prev,
);
}
function ToolCallList({
calls,
order,
}: {
calls: Record<string, ToolCall>;
order: string[];
}) {
if (order.length === 0) {
return (
<p className="mt-2 text-sm text-blue-950">Waiting for tool calls...</p>
);
}
return (
<ol className="mt-3 grid gap-2">
{order.map((id) => {
const call = calls[id];
if (!call) return null;
const label = TOOL_LABELS[call.toolName] ?? call.toolName;
return (
<li key={id} className="flex items-center gap-2 text-sm">
<ToolStatusGlyph status={call.status} />
<span className="text-blue-950">{label}</span>
</li>
);
})}
</ol>
);
}
function ToolStatusGlyph({ status }: { status: ToolCall["status"] }) {
if (status === "calling") {
return <span className="text-blue-700">...</span>;
}
if (status === "error") {
return <span className="text-red-600">x</span>;
}
return <span className="text-emerald-700"></span>;
}

When the workflow writes data-status-change, AgentStream calls router.refresh(). The Server Component reloads from Upstash Redis storage and the stream component unmounts because the post is no longer under_review.

Mount AgentStream inside the post list in app/page.tsx. Add the import:

import { AgentStream } from "./agent-stream";

Then render the stream component for posts that are still under review and have a workflow run ID:

app/page.tsx
{post.status === "under_review" && post.runId ? (
<AgentStream postId={post.id} />
) : null}
Review desk streaming workflow progress with steps and audit trail

At this stage, the workflow can classify posts durably and stream progress into the forum. The next sections introduce the human-in-the-loop pattern to handle human escalations rather than leaving them under review.

Install the Chat SDK and its Slack adapter:

pnpm add chat @chat-adapter/slack @chat-adapter/state-redis

To opt out Chat SDK from workflow bundling, update next.config.ts:

import type { NextConfig } from "next";
import { withWorkflow } from "workflow/next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@chat-adapter/slack", "@slack/socket-mode"],
};
export default withWorkflow(nextConfig);

In a second terminal, expose the local server through ngrok:

ngrok http 3000

Copy the https forwarding URL from ngrok. Slack uses that public URL to reach your local webhook during development.

Create a Slack app and configure it with the following manifest:

display_information:
name: Review Desk
description: Human-in-the-loop moderation demo
background_color: "#111111"
features:
bot_user:
display_name: Review Desk
always_online: false
oauth_config:
scopes:
bot:
- chat:write
- chat:write.public
- users:read
settings:
interactivity:
is_enabled: true
request_url: https://<your-public-url>/api/webhooks/slack
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false

Use the ngrok forwarding URL in the Slack manifest as https://<your-public-url>/api/webhooks/slack. The app gives you the bot token, signing secret, scopes, and interactivity URL that the code depends on.

Install the app to your workspace, invite it to the moderation queue channel, then copy these values into .env.local:

  • Bot token: SLACK_BOT_TOKEN
  • Signing secret: SLACK_SIGNING_SECRET
  • Mod queue channel ID: SLACK_MOD_QUEUE_CHANNEL_ID
SLACK_BOT_TOKEN=xoxb-...
SLACK_SIGNING_SECRET=...
SLACK_MOD_QUEUE_CHANNEL_ID=C0123ABCDE

Use a channel ID for SLACK_MOD_QUEUE_CHANNEL_ID, not a channel name like #mod-queue.

To send Slack messages for escalations create helper functions. Create lib/post-mod-card.ts:

import { createSlackAdapter } from "@chat-adapter/slack";
import {
Actions,
Button,
Card,
CardText,
Divider,
Field,
Fields,
type CardElement,
} from "chat";
import { appendAudit, getPost, getUser } from "./db";
import { actionIdFor } from "./types";
import type { ActionId, TriageOutput } from "./types";
let slackAdapter: ReturnType<typeof createSlackAdapter> | undefined;
function getSlackAdapter() {
slackAdapter ??= createSlackAdapter();
return slackAdapter;
}

The postModCard function gathers the post context, builds the Slack card, sends it to the moderation channel, and stores an audit entry.

lib/post-mod-card.ts
export async function postModCard(args: {
postId: string;
triage: TriageOutput;
hookToken: string;
}): Promise<PostedModCard> {
"use step";
const channelId = process.env.SLACK_MOD_QUEUE_CHANNEL_ID;
if (!channelId) throw new Error("SLACK_MOD_QUEUE_CHANNEL_ID is not set");
const post = await getPost(args.postId);
if (!post) throw new Error(`Post ${args.postId} not found`);
const author = await getUser(post.authorId);
const authorName = author?.name ?? post.authorId;
const options =
args.triage.actionOptions && args.triage.actionOptions.length > 0
? args.triage.actionOptions
: defaultOptionsFor(args.triage.decision);
const reasoning =
args.triage.reasoning?.trim() || "(agent did not provide reasoning)";
const question =
args.triage.humanRequest?.trim() || "This post needs a moderator's call.";
const body = post.body?.trim() || "(empty post)";
const snapshot = buildModCardSnapshot(args.postId, authorName, body, {
...args.triage,
reasoning,
humanRequest: question,
});
const card = buildModCard({
snapshot,
options,
hookToken: args.hookToken,
});
const fallbackText = `Mod queue · ${args.triage.category} · ${authorName}: ${truncate(body, 140)}`;
const sent = await getSlackAdapter().postChannelMessage(
`slack:${channelId}`,
{
card,
fallbackText,
},
);
await appendAudit({
postId: args.postId,
action: "escalated",
actorId: "agent",
note: question,
});
return {
threadId: getSlackThreadId(channelId, sent.id),
messageId: sent.id,
snapshot,
};
}

Add the helper functions to update the original card after a moderator responds:

lib/post-mod-card.ts
export async function markModCardResolved(args: {
threadId: string;
messageId: string;
snapshot: ModCardSnapshot;
moderator: string;
action: ActionId;
}): Promise<void> {
"use step";
await getSlackAdapter().editMessage(args.threadId, args.messageId, {
card: buildResolvedModCard(args),
fallbackText: `Resolved by ${args.moderator} · ${args.action}`,
});
}
export async function postModThreadReply(args: {
threadId: string;
moderator: string;
action: ActionId;
}): Promise<void> {
"use step";
await getSlackAdapter().postMessage(
args.threadId,
`${args.moderator} chose ${args.action}`,
);
}

Next, add the card rendering functions. The unresolved card includes buttons, while the resolved card replaces those buttons with a final status line.

lib/post-mod-card.ts
function buildModCard(args: {
snapshot: ModCardSnapshot;
options: { label: string; action: ActionId }[];
hookToken: string;
}): CardElement {
const { snapshot } = args;
return Card({
title: `Mod queue · ${snapshot.category}`,
children: [
Fields([
Field({ label: "Post", value: snapshot.postId }),
Field({ label: "Severity", value: snapshot.severity }),
Field({ label: "Confidence", value: snapshot.confidence.toFixed(2) }),
Field({ label: "Decision", value: snapshot.decision }),
]),
CardText(
`**${snapshot.authorName}** wrote:\\n\\n${blockquote(snapshot.body)}`,
),
Divider(),
CardText(`**Agent reasoning**\\n${snapshot.reasoning}`),
CardText(`**Moderator**\\n${snapshot.question}`),
Actions(
args.options.map((opt) =>
Button({
id: actionIdFor(opt.action),
label: opt.label,
style: buttonStyleFor(opt.action),
value: JSON.stringify({
token: args.hookToken,
action: opt.action,
}),
}),
),
),
],
});
}
function buildResolvedModCard(args: {
snapshot: ModCardSnapshot;
moderator: string;
action: ActionId;
}): CardElement {
const { snapshot } = args;
return Card({
title: `Mod queue · ${snapshot.category}`,
children: [
Fields([
Field({ label: "Post", value: snapshot.postId }),
Field({ label: "Severity", value: snapshot.severity }),
Field({ label: "Confidence", value: snapshot.confidence.toFixed(2) }),
Field({ label: "Decision", value: snapshot.decision }),
]),
CardText(
`**${snapshot.authorName}** wrote:\\n\\n${blockquote(snapshot.body)}`,
),
Divider(),
CardText(`**Agent reasoning**\\n${snapshot.reasoning}`),
CardText(`**Moderator**\\n${snapshot.question}`),
CardText(`✅ Resolved by **${args.moderator}** · \\`${args.action}\\``),
],
});
}

Add the default options for buttons:

lib/post-mod-card.ts
function buildModCardSnapshot(
postId: string,
authorName: string,
body: string,
triage: TriageOutput,
): ModCardSnapshot {
return {
postId,
authorName,
body,
category: triage.category,
severity: triage.severity,
confidence: triage.confidence,
decision: triage.decision,
reasoning: triage.reasoning,
question: triage.humanRequest ?? "This post needs a moderator's call.",
};
}
function buttonStyleFor(action: ActionId): "primary" | "danger" | undefined {
if (action === "ban" || action === "hide" || action === "hide_and_warn") {
return "danger";
}
if (action === "restore" || action === "dismiss") {
return "primary";
}
return undefined;
}
function defaultOptionsFor(
decision: TriageOutput["decision"],
): { label: string; action: ActionId }[] {
if (decision === "request_ban") {
return [
{ label: "Approve ban", action: "ban" },
{ label: "Just warn", action: "hide_and_warn" },
{ label: "Restore", action: "restore" },
];
}
return [
{ label: "Confirm violation", action: "hide" },
{ label: "Restore", action: "restore" },
{ label: "Dismiss", action: "dismiss" },
];
}

Add the formatting helpers and the shared card types at the end of the file:

lib/post-mod-card.ts
function blockquote(s: string): string {
return s
.split("\\n")
.map((line) => `> ${line}`)
.join("\\n");
}
function truncate(s: string, max: number): string {
return s.length <= max ? s : `${s.slice(0, max - 1)}`;
}
function getSlackThreadId(channelId: string, messageTs: string): string {
return `slack:${channelId}:${messageTs}`;
}
type PostedModCard = {
threadId: string;
messageId: string;
snapshot: ModCardSnapshot;
};
type ModCardSnapshot = {
postId: string;
authorName: string;
body: string;
category: string;
severity: string;
confidence: number;
decision: TriageOutput["decision"];
reasoning: string;
question: string;
};

The Chat SDK Slack adapter renders the card to Slack Block Kit, posts it to the moderation channel, edits it after the workflow resumes, and posts the thread reply. The card button values carry the workflow hook token and the action to apply.

Now that the Slack helpers, bot, and webhook route exist, update workflows/triage.ts so escalated posts pause for a moderator rather than only staying in under_review.

Update the imports and add the timeout constant:

import { createHook, getWritable, sleep } from "workflow";
import { hasToolCall, type UIMessageChunk } from "ai";
import { executeAction } from "@/lib/actions";
import {
markModCardResolved,
postModCard,
postModThreadReply,
} from "@/lib/post-mod-card";
import { appendAudit, getPost, setPostStatus } from "@/lib/db";
import { createTriageAgent } from "@/lib/triage-agent";
import { hookTokenForPost } from "@/lib/types";
import type { ActionId, Post, TriageOutput } from "@/lib/types";
const HUMAN_TIMEOUT = "48h";

Update the escalation branch in applyTriageDecision():

workflows/triage.ts
async function applyTriageDecision(
postId: string,
triage: TriageOutput | undefined,
): Promise<void> {
if (!triage) {
await setUnderReviewToLive(
postId,
"agent did not call submitTriage, workflow could not route post",
);
await pingStatusChange(postId);
} else {
// ...
} else {
await askHumanModerator(postId, triage);
await pingStatusChange(postId);
}
}
}

Replace keepUnderReviewForHuman() with askHumanModerator() , so it calls the Slack pause path and refreshes the UI after the Slack path completes:

workflows/triage.ts
async function askHumanModerator(postId: string, triage: TriageOutput) {
const hookToken = hookTokenForPost(postId);
const hook = createHook<{ action: ActionId; moderator: string }>({
token: hookToken,
});
const modCard = await postModCard({ postId, triage, hookToken });
await pingStatusChange(postId);
const decision = await Promise.race([
hook,
sleep(HUMAN_TIMEOUT).then(() => ({
action: "dismiss" as ActionId,
moderator: "system",
})),
]);
await executeAction(
postId,
decision.action,
triage.draftedWarning,
decision.moderator,
);
await pingStatusChange(postId);
await markModCardResolved({
...modCard,
moderator: decision.moderator,
action: decision.action,
});
await postModThreadReply({
threadId: modCard.threadId,
moderator: decision.moderator,
action: decision.action,
});
}

createHook() from the Workflows SDK creates a typed suspension point inside a workflow. The workflow pauses while awaiting the hook. On Vercel, you don’t get charged while the workflow is waiting for a hook or sleep call.

Promise.race() is the human-in-the-loop contract: either a moderator responds, or the workflow falls back after 48 hours. Because the wait happens inside the workflow, there’s no separate timeout table or background process to reconcile later.

In postModCard(), a deterministic hook token connects the Slack button back to the exact suspended workflow. The app generates a token from the post ID, posts the Slack card, and waits for either a moderator's decision or a fallback timeout.

The workflow resumes with the decision and passes it to executeAction(). markModCardResolved() marks the Slack message as resolved so other moderators can see it, and postModThreadReply() posts a confirmation reply in the thread.

To listen to Slack events for button clicks, create a Slack bot using the Chat SDK in lib/bot.ts:

import { createSlackAdapter } from "@chat-adapter/slack";
import { createRedisState } from "@chat-adapter/state-redis";
import { Chat, type Author } from "chat";
import { resumeHook } from "workflow/api";
import { ActionIdSchema, ALL_MOD_ACTION_IDS } from "./types";
const adapters = { slack: createSlackAdapter() };
const redisUrl = process.env.REDIS_URL;
if (!redisUrl) {
throw new Error(
"Redis state requires REDIS_URL, KV_URL, or UPSTASH_REDIS_URL. " +
"Upstash REST variables are used by @upstash/redis, but " +
"@chat-adapter/state-redis requires a Redis connection URL.",
);
}
export const bot = new Chat({
userName: "review-desk",
adapters,
state: createRedisState({ url: redisUrl, keyPrefix: "review-desk:chat" }),
logger: "info",
});

Register the Slack button click handler:

lib/bot.ts
bot.onAction([...ALL_MOD_ACTION_IDS], async (event) => {
if (!event.value) return;
let parsed: { token: string; action: string };
try {
parsed = JSON.parse(event.value) as { token: string; action: string };
} catch (err) {
console.error("[slack] failed to parse action value", err);
return;
}
const action = ActionIdSchema.parse(parsed.action);
const moderator = await getModeratorDisplayName(event.user);
try {
await resumeHook(parsed.token, { action, moderator });
} catch (err) {
console.warn("[slack] resumeHook rejected", {
token: parsed.token,
err,
});
}
});
async function getModeratorDisplayName(user: Author): Promise<string> {
try {
const profile = await bot.getUser(user);
const displayName = profile?.userName?.trim();
if (displayName && displayName !== "unknown") {
return displayName;
}
} catch (err) {
console.warn("[slack] failed to fetch moderator profile", {
userId: user.userId,
err,
});
}
return firstKnownName(user.fullName, user.userName, user.userId);
}
function firstKnownName(...names: string[]): string {
return names.find((name) => name.trim() && name !== "unknown") ?? "unknown";
}

The resumeHook() advances the workflow with the unique token. If clicked twice, the second click tries to resume a consumed hook, throws an exception, and exits.

Keep the Slack adapter imports dynamic. Static imports can pull Node-specific adapter code into the workflow bundle, leading to runtime errors.

To handle Slack requests, create a Next.js route handler using the Chat SDK Slack adapter, which verifies the request signature, parses the payload, and dispatches to the bot’s onAction.

Create app/api/webhooks/slack/route.ts:

import { type NextRequest } from "next/server";
import { bot } from "@/lib/bot";
export async function POST(request: NextRequest): Promise<Response> {
return bot.webhooks.slack(request);
}

Start the app, and keep the ngrok tunnel running:

pnpm dev

Create the same post with spammy content and the agent automatically escalates it to Slack:

Review Desk sends Slack escalation message with context and action buttons

After approving the ban, the Slack message gets updated with the moderator’s action:

Review Desk updated Slack escalation message after ban

The forum also updates with the post status as hidden and audit log showing the trail of action:

Review Desk hidden post audit trail

You can also inspect the workflow steps individually by running:

npx workflow web
Workflow SDK local world visualization

Try the Review Desk repository. It includes Vercel Workflows, Next.js, and Chat SDK integration, along with instructions for setting everything up in the README.

This project generalizes beyond moderation. The same primitives apply to refunds, flagged transactions, agent tool approvals, deploy gates, and any use case that requires human intervention.

Vercel Workflows provide these use cases with a durable place to wait, delivering a great developer experience. A workflow can pause at a human checkpoint, preserve its state, and resume from the same run when an answer comes back.

Review Desk Template

Community Moderation platform with escalations to Slack. Ban, warn, or mark as spam without leaving the Slack channel.

Deploy Template

Was this helpful?

supported.