Building stateful Slack bots with Vercel Workflow

Learn how to build Slack bots that maintain state and handle long-running processes without managing queues, databases, or background job infra.

3 min read
Last updated October 23, 2025

Imagine writing a function that can stop mid-execution and resume hours, days, or even weeks later without losing its place or any of its variables. No checkpoints. No state machines. No external storage. Just a function that time-travels.

Vercel Workflow is a fully managed implementation of the open source Workflow Development Kit (WDK), a TypeScript framework for building apps and AI Agents that can resume, suspend, and maintain state with ease.

With Vercel Workflow, a function can suspend itself until a desired state (e.g. a webhook event arriving), then pick up exactly where it left off with full state and stack continuity.

Here's an example of what this kind of function might look like:

import { defineHook } from "workflow";
export const messageHook = defineHook<{text: string}>();
export async function conversation(conversationId: string) {
"use workflow";
let history = []; // This survives across executions
const messages = messageHook.create({ token: `chat-${conversationId}` });
// This loop pauses and resumes as events arrive
for await (const message of messages) {
history.push(message.text);
console.log(`History: ${history.join(', ')}`);
if (history.length >= 5) break;
}
console.log("Conversation complete!");
}
// Webhook receives events and resumes the workflow
export async function POST(req: Request) {
const { chatId, text } = await req.json();
await messageHook.resume(`chat-${conversationId}`, { text });
return new Response("OK");
}

We'll get into how this works in the guide below.

Let’s dive into a Slack bot that invites users to write a story together with AI.

When a user runs /storytime in Slack, the bot begins a collaborative story in a thread. Users add messages one at a time, and the AI continues the story after each contribution. When the story reaches a natural ending, the bot generates an image based on the full story.

A user interacting with Storytime Slack bot
A user interacting with Storytime Slack bot
A user interacting with Storytime Slack bot
A user interacting with Storytime Slack bot

The key is that the workflow powering this bot pauses after each message and resumes when a new one arrives, using a workflow hook. This allows the function to maintain the entire story state over time without a database or any manual state management.

Now let's go through the code.

import { defineHook } from "workflow";
import { generateStoryPiece, generateStoryboardImage } from "./steps";
import { postToSlack } from "./lib/slack";
const slackMessageHook = defineHook<{text: string}>();
export async function storytime(slashCommand: URLSearchParams) {
"use workflow";
const channelId = slashCommand.get("channel_id");
let messages = [];
let finalStory = "";
// Generate AI story introduction
const intro = await generateStoryPiece(messages);
const { ts } = await postToSlack(`Story started: ${intro.story}`);
// Create hook to listen for Slack messages in this thread
const slackMessages = slackMessageHook.create({
token: `story-${channelId}-${ts}`
});
// Wait for users to contribute - this is where the magic happens
for await (const userMessage of slackMessages) {
messages.push({ role: "user", content: userMessage.text });
const aiResponse = await generateStoryPiece(messages);
await postToSlack(aiResponse.encouragement);
if (aiResponse.done) {
finalStory = aiResponse.story;
break;
}
}
// Story complete - generate final image
await generateStoryboardImage(finalStory);
}
// Webhook receives Slack messages and resumes the workflow
export async function POST(req: Request) {
const slackEvent = await req.json();
const { channel, thread_ts, text } = slackEvent.event;
const token = `story-${channel}-${thread_ts}`;
await slackMessageHook.resume(token, { text });
return new Response("OK");
}
  1. User types /storytime → Workflow starts, creates hook with unique token
  2. AI generates introduction → Posts to Slack
  3. Function pauses → Waits at for await loop for user messages
  4. User adds to story → Slack webhook receives event
  5. Webhook resumes workflow → Uses token to find paused workflow
  6. AI continues story → Processes with full conversation history
  7. Repeat until complete → Each message resumes the workflow
  8. Generate final image → Workflow completes naturally

The messages array grows with each interaction, the AI always has full context, and you never think about databases or state management. This complete message history can then be used to generate the final image.

An image generated by Storytime Slack bot
An image generated by Storytime Slack bot

The key insight is that webhooks don't just receive events, they resume paused workflows with perfect state preservation.

Workflows are stateful functions that can pause and resume:

export async function myWorkflow(items: string[]) {
"use workflow"; // This makes it stateful
let processedCount = 0;
let results = [];
// Workflow is suspended for processing of each item
for (const item of items) {
const result = await processData(item);
results.push(result);
processedCount++;
// Send progress updates
await sendNotification(`Processed ${processedCount}/${items.length} items`);
// State survives across async operations and potential pauses
if (processedCount % 5 === 0) {
await sendSummary(results.slice(-5)); // Last 5 results
}
}
return { processedCount, results };
}

Steps are reliable, retryable operations within workflows:

export async function processData(data: string) {
"use step";
// If the external api request were to fail, the entire step would be retried
const result = await externalAPI.process(data);
return result;
}
export async function sendNotification(message: string) {
"use step";
await emailService.send(message);
}

Hooks let you pass data from external events and resume workflow execution:

  1. Define what an event looks like
import { defineHook } from "workflow";
const messageHook = defineHook<{text: string, userId: string}>();

2. Create a listener in your workflow

export async function myWorkflow() {
"use workflow";
const events = messageHook.create({
token: "unique-workflow-identifier"
});
for await (const event of events) {
// Process event with full workflow state
console.log("Received event:", event);
}
}

3. Resume the hook when events arrive

export async function POST(req: Request) {
const data = await req.json();
await messageHook.resume("unique-workflow-identifier", {
text: data.message,
userId: data.user
});
return new Response("OK");
}

The token is the key. It's given to the hook with the corresponding token which identifies the correct workflow to resume and run.

In addition to hooks, a mechanism to orchestrate workflow pausing includes using a timer.

In traditional serverless development, functions are stateless by design. To maintain continuity, you have to scatter state across databases, message queues, and more. With workflows, your functions are stateful. They listen. They pause. They resume. They remember.

Before: "How do I coordinate these distributed systems?"

After: "What should happen when the user does X?"

Before: "Where do I store this state? How do I handle failures?"

After: "This variable holds the state. It just works."

Vercel Workflows let's you write simple functions that just work, even for complex stateful processes.

You can now build sophisticated applications by focusing on what should happen, not how to coordinate infrastructure.

The Storytime bot shows how complex, interactive systems become simple when you have the right abstractions.

Try it yourself by deploying the complete Storytime Slack bot example to see workflows in action.

Was this helpful?

supported.