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:
- Node.js 22 or later
- An existing Chat SDK bot (see the Slack agent guide or Slack file bot guide)
- A Vercel account
- ngrok CLI installed
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:
- A new post starts a workflow run.
- The agent gathers context and triages the action.
- Clear decisions are applied immediately.
- Borderline decisions create a hook token and post a Slack message for review.
- The workflow pauses until the moderator clicks or the timeout fires.
- The Slack handler resumes the hook with the selected action.
- The workflow applies the final decision and updates the UI and the Slack message.
To install the packages, run the following command:
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:
Start the app by running:
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:
Then replace the AI SDK ToolLoopAgent in lib/triage-agent.ts with a DurableAgent. Replace the imports at the top of the file with:
Then replace runTriage() with createTriageAgent():
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 :
Inside the workflow, runTriageAgent() creates a triage agent that runs a tool-calling loop:
Add applyTriageDecision() to execute the triaged decision:
Add keepUnderReviewForHuman() to record the escalation while the post stays under review:
Add the workflow step helpers that read and update the post state:
Finally, emit a custom stream chunk whenever the workflow writes state that the Server Component renders, such as post status or audit entries.
To start the workflow when a user submits a post, you need to use Next.js Server Actions. Create app/actions.ts:
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:
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:
The component subscribes on mount, updates local tool-call state, and refreshes the Server Component when the workflow emits the status-change chunk.
The stream reader converts the response body into UIMessageChunks.
The chunk handler maps workflow stream events to local UI state.
Finish the component file with the following rendering helpers:
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:
Then render the stream component for posts that are still under review and have a workflow run ID:

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:
To opt out Chat SDK from workflow bundling, update next.config.ts:
In a second terminal, expose the local server through ngrok:
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:
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
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:
The postModCard function gathers the post context, builds the Slack card, sends it to the moderation channel, and stores an audit entry.
Add the helper functions to update the original card after a moderator responds:
Next, add the card rendering functions. The unresolved card includes buttons, while the resolved card replaces those buttons with a final status line.
Add the default options for buttons:
Add the formatting helpers and the shared card types at the end of the file:
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:
Update the escalation branch in applyTriageDecision():
Replace keepUnderReviewForHuman() with askHumanModerator() , so it calls the Slack pause path and refreshes the UI after the Slack path completes:
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:
Register the Slack button click handler:
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:
Start the app, and keep the ngrok tunnel running:
Create the same post with spammy content and the agent automatically escalates it to Slack:

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

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

You can also inspect the workflow steps individually by running:

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.