Skip to content

Building a Slack agent with durable workflows

Build an AI-powered Slack bot that gathers team data, drafts a summary, and refines it through conversation.

14 min read
Last updated April 3, 2026

A slack bot that calls AI models have a problem: they time out. A bot that needs to fetch data from multiple sources, run an AI agent, and wait for human feedback will exceed Slack's 3-second response window and most serverless function limits. If the process crashes mid-way, the work is lost.

Durable workflows solve this. Each step persists its result, so the workflow can pause for hours (waiting for a user reply) or retry after a failure without re-running completed work. This guide builds a Slack agent that gathers Slack and GitHub activity, drafts a weekly summary with an AI agent, and refines it through a conversational loop in a Slack thread.

Prerequisites

Before starting, you need:

  • A Next.js app with the Workflow SDK installed
  • A Slack app with a bot token (xoxb-...) and scopes: channels:history, channels:read, chat:write, users:read
  • A GitHub personal access token with read access to your repos
  • Event subscriptions and interactivity enabled in your Slack app, both pointing at your deployment URL

Set these in your .env.local:

SLACK_BOT_TOKEN=xoxb-...
SLACK_SIGNING_SECRET=...
GITHUB_TOKEN=ghp_...

For AI Gateway, run vercel env pull to get the OIDC token for local development.

Example

We'll build a Slack bot that produces a weekly team summary. A manual trigger kicks off the workflow, which gathers data from Slack channels and GitHub repos, hands it to an AI agent, posts the draft to a review channel, and then enters an interactive refinement loop where team members give feedback in a thread until they confirm and publish.

We'll start by gathering data with step functions, then draft with a DurableAgent, then wire up the refinement loop with iterable hooks, and finally connect Slack events to resume the workflow. The code samples come from our workflow-dogfooding project.

Step 1: Gather data with step functions

The workflow needs Slack messages and GitHub PRs from the past week. Both require API calls that can fail, so they belong in step functions.

Step functions use the "use step" directive. They get automatic retry, persistent results (so replays skip completed steps), and full Node.js access. The workflow function itself runs in a sandboxed VM where direct API calls would fail.

import { slackApi } from "../slack/api";
export async function fetchSlackMessages(
channelId: string,
startDate: string,
endDate: string
) {
"use step";
const oldest = (new Date(startDate).getTime() / 1000).toString();
const latest = (new Date(endDate).getTime() / 1000).toString();
const data = await slackApi("conversations.history", {
channel: channelId,
oldest,
latest,
limit: 200,
});
return (data.messages ?? [])
.filter((msg: Record<string, unknown>) => !msg.subtype)
.map((msg: Record<string, unknown>) => ({
user: msg.user as string,
text: msg.text as string,
ts: msg.ts as string,
}));
}
import { Octokit } from "@octokit/rest";
export async function fetchGitHubPRs(
owner: string,
repo: string,
since: string,
until: string
) {
"use step";
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const sinceDate = new Date(since);
const untilDate = new Date(until);
const { data } = await octokit.pulls.list({
owner,
repo,
state: "all",
sort: "updated",
direction: "desc",
per_page: 100,
});
return data
.filter((pr) => {
const updated = new Date(pr.updated_at);
return updated >= sinceDate && updated <= untilDate;
})
.map((pr) => ({
number: pr.number,
title: pr.title,
user: pr.user?.login ?? "unknown",
state: pr.merged_at ? "merged" : pr.state,
url: pr.html_url,
}));
}

Now the workflow can gather data in parallel. If one API call fails, the step retries automatically. If the entire workflow restarts, completed steps return their cached results without re-fetching.

import { fetchSlackMessages } from "../steps/slack";
import { fetchGitHubPRs } from "../steps/github";
import { postSlackMessage } from "../steps/slack";
export async function weeklySummaryWorkflow() {
"use workflow";
const endDate = new Date().toISOString();
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
// Post a placeholder while we work
const header = await postSlackMessage(
REVIEW_CHANNEL,
"Weekly Summary — Generating..."
);
// Fetch data in parallel
const [slackMessages, prs] = await Promise.all([
fetchSlackMessages("C09G3EQAL84", startDate, endDate),
fetchGitHubPRs("vercel", "workflow", startDate, endDate),
]);
// ... next: pass data to an AI agent
}

postSlackMessage is also a step function. That means the header message's timestamp (header.ts) is persisted. If the workflow replays, it gets the same timestamp back and can update the same message instead of posting a duplicate.

Step functions and the workflow sandbox

Why not call fetch or Octokit directly in the workflow function? Workflow functions run in a sandboxed VM that blocks network access. Calling fetch there will fail. You can either move I/O into step functions (which run in normal Node.js with full access to npm packages and environment variables) or import the workflow-aware fetch with import { fetch } from "workflow". Step functions are the better default because they also give you automatic retry and cached results on replay.

Step 2: Draft with a DurableAgent

With the data gathered, we hand it to an AI agent. DurableAgent from @workflow/ai turns each LLM call and tool invocation into its own durable step. Each step gets the full serverless function timeout and retries on failure automatically.

The agent gets tools that call step functions. When the agent decides it needs more context (a PR's full description, a Slack thread's replies), it calls a tool, which runs as a step with automatic retry.

import { z } from "zod";
import { fetchGitHubPRDetail } from "../steps/github";
import { fetchSlackThread } from "../steps/slack";
export function createResearchTools() {
return {
fetchPRDetail: {
description: "Fetch details and comments for a specific GitHub PR.",
inputSchema: z.object({
owner: z.string(),
repo: z.string(),
prNumber: z.number(),
}),
execute: async ({ owner, repo, prNumber }: {
owner: string;
repo: string;
prNumber: number;
}) => {
const detail = await fetchGitHubPRDetail(owner, repo, prNumber);
return {
title: detail.title,
body: detail.body.slice(0, 2000),
state: detail.state,
url: detail.url,
};
},
},
fetchSlackThread: {
description: "Fetch replies from a Slack thread for more context.",
inputSchema: z.object({
channelId: z.string(),
threadTs: z.string(),
}),
execute: async ({ channelId, threadTs }: {
channelId: string;
threadTs: string;
}) => {
const messages = await fetchSlackThread(channelId, threadTs);
return messages;
},
},
};
}

Notice: the tool execute functions call step functions internally. They don't need "use step" themselves because they run at the workflow level, which is exactly where they need to be to call other steps.

Now add the agent to the workflow:

import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import type { UIMessageChunk } from "ai";
import { createResearchTools } from "../tools/research-tools";
import { updateSlackMessage } from "../steps/slack";
export async function weeklySummaryWorkflow() {
"use workflow";
// ... (data gathering from Step 1)
const agent = new DurableAgent({
model: "anthropic/claude-sonnet-4-5",
system: `You are a technical writer creating a weekly engineering summary.
Use the research tools to investigate PRs and threads when you need more context.`,
tools: createResearchTools(),
});
const result = await agent.stream({
messages: [{
role: "user",
content: `Summarize this week's activity:\n\n${JSON.stringify({ slackMessages, prs })}`,
}],
writable: getWritable<UIMessageChunk>(),
maxSteps: 50,
});
// Extract text from the last assistant message
const draft = extractText(result.messages);
// Update the placeholder with the draft
await updateSlackMessage(REVIEW_CHANNEL, header.ts, draft);
// ... next: start the refinement loop
}
function extractText(messages: Array<{ role: string; content: unknown }>): string {
const last = [...messages].reverse().find((m) => m.role === "assistant");
if (!last) return "";
if (typeof last.content === "string") return last.content;
if (Array.isArray(last.content)) {
return last.content
.filter((p: { type: string }) => p.type === "text")
.map((p: { text: string }) => p.text)
.join("");
}
return "";
}

getWritable<UIMessageChunk>() streams the agent's output to the workflow run's default stream. If you're watching the run in the workflow dashboard (npx workflow web), you'll see tokens arriving in real time. maxSteps caps how many LLM calls the agent can make (each tool use counts as a step).

Why DurableAgent instead of raw AI SDK?

The raw AI SDK runs the entire agent loop in a single function invocation. If that function times out or crashes, all progress is lost. DurableAgent splits the loop into individual steps. A 50-step research agent is 50 separate invocations. Each one gets the full timeout, and completed steps are never re-run.

The conversation state (the messages array, the current draft) also persists automatically. In the refinement workflow, a team member can give feedback on Monday and another can follow up on Wednesday. The workflow picks up where it left off. No database needed.

Step 3: Refine with iterable hooks

The draft is posted. Now users reply in the Slack thread with feedback, and the agent refines until they click "Confirm & Publish". This loop can last hours or days. A team member might give feedback Monday morning and another might follow up Wednesday afternoon. No serverless function can hold a connection that long, and you would normally need a database to persist the conversation. Hooks solve the first problem. Durability solves the second. The messages array and currentDraft survive across every pause without any external storage.

A hook suspends the workflow until an external event resumes it. Iterable hooks (for await...of) receive multiple events in sequence, one per resumeHook call.

import { DurableAgent } from "@workflow/ai/agent";
import { createHook, getWritable } from "workflow";
import type { UIMessageChunk } from "ai";
import {
postSlackMessage,
postSlackReply,
updateSlackMessage,
} from "../steps/slack";
import { createResearchTools } from "../tools/research-tools";
import { z } from "zod";
type HookEvent =
| { type: "reply"; text: string }
| { type: "confirm" }
| { type: "cancel" };
export async function draftRefinementWorkflow(input: {
reviewChannel: string;
publishChannel: string;
headerTs: string;
initialDraft: string;
}) {
"use workflow";
let currentDraft = input.initialDraft;
const agent = new DurableAgent({
model: "anthropic/claude-sonnet-4-5",
system: "You refine weekly summaries based on user feedback. When given feedback, call updateDraft with the revised text, then reply with a brief note about what you changed.",
tools: {
...createResearchTools(),
updateDraft: {
description: "Update the draft in the main message.",
inputSchema: z.object({ updatedDraft: z.string() }),
execute: async ({ updatedDraft }: { updatedDraft: string }) => {
currentDraft = updatedDraft;
await updateSlackMessage(
input.reviewChannel,
input.headerTs,
currentDraft
);
return "Draft updated. Reply with a brief acknowledgment.";
},
},
},
});
const messages: Array<{ role: "user" | "assistant"; content: string }> = [
{ role: "user", content: `Current draft:\n\n${input.initialDraft}\n\nWait for feedback.` },
];
// Deterministic token so Slack event handlers can reconstruct it
const hook = createHook<HookEvent>({
token: `draft-${input.headerTs}`,
});
for await (const event of hook) {
if (event.type === "cancel") break;
if (event.type === "confirm") {
await postSlackMessage(input.publishChannel, currentDraft);
await postSlackReply(
input.reviewChannel,
input.headerTs,
"Published!"
);
break;
}
// event.type === "reply"
messages.push({ role: "user", content: event.text });
const result = await agent.stream({
messages,
writable: getWritable<UIMessageChunk>(),
maxSteps: 50,
});
const text = extractText(result.messages);
if (text) {
messages.push({ role: "assistant", content: text });
await postSlackReply(input.reviewChannel, input.headerTs, text);
}
}
}
function extractText(messages: Array<{ role: string; content: unknown }>): string {
const last = [...messages].reverse().find((m) => m.role === "assistant");
if (!last) return "";
if (typeof last.content === "string") return last.content;
if (Array.isArray(last.content)) {
return last.content
.filter((p: { type: string }) => p.type === "text")
.map((p: { text: string }) => p.text)
.join("");
}
return "";
}

The key pattern: createHook takes a token parameter. This token is deterministic, built from the Slack message timestamp (headerTs). When a user replies in the thread, the Slack event handler can reconstruct this token from the event's thread_ts and call resumeHook to deliver the event. The workflow wakes up, processes the event, and suspends again at the top of the loop.

Between iterations, the workflow consumes zero compute. It's fully suspended. The conversation state (messages array, currentDraft) lives in the workflow's durable context.

Deterministic tokens

The hook token draft-${input.headerTs} is deterministic because headerTs is the Slack message timestamp, which is stable and unique. This matters because the Slack event handler runs in a separate HTTP request with no access to the workflow's internal state. It can only resume a hook if it can reconstruct the token from information available in the Slack event payload (the thread_ts field).

If you used a random token, you'd need a database to map message timestamps to hook tokens. Deterministic tokens eliminate that.

Step 4: Connect Slack events to the workflow

The workflow is suspended at for await (const event of hook). Now we need Slack events to resume it. This requires two API routes: one for events (thread replies), one for interactions (button clicks).

import { resumeHook } from "workflow/api";
import { verifySlackRequest } from "@/lib/slack/verify";
export async function POST(req: Request) {
const cloned = req.clone();
const rawJson = await cloned.json();
// Handle Slack's one-time URL verification
if (rawJson.type === "url_verification") {
return Response.json({ challenge: rawJson.challenge });
}
// Verify request signature
const rawBody = await verifySlackRequest(req);
const body = JSON.parse(rawBody);
const event = body.event;
// Thread reply from a user (not a bot)
if (event?.type === "message" && event.thread_ts && !event.bot_id) {
try {
await resumeHook(`draft-${event.thread_ts}`, {
type: "reply",
text: event.text,
});
} catch {
// Hook may not exist for this thread
}
}
return new Response("ok");
}
import { resumeHook } from "workflow/api";
import { verifySlackRequest } from "@/lib/slack/verify";
export async function POST(req: Request) {
const rawBody = await verifySlackRequest(req);
const params = new URLSearchParams(rawBody);
const payload = JSON.parse(params.get("payload") ?? "{}");
if (payload.type === "block_actions") {
for (const action of payload.actions ?? []) {
const headerTs = payload.message?.thread_ts ?? payload.message?.ts;
if (!headerTs) continue;
if (action.action_id === "confirm_draft") {
try {
await resumeHook(`draft-${headerTs}`, { type: "confirm" });
} catch {
// Hook may not exist for this message
}
}
}
}
return new Response("", { status: 200 });
}

The events route handles thread replies. When a user types feedback in the draft thread, Slack sends a message event with thread_ts pointing to the header message. The handler reconstructs the hook token (draft-${event.thread_ts}) and calls resumeHook with the reply text. The workflow wakes up, the agent processes the feedback, updates the draft, and suspends again.

The interactions route handles button clicks. When a user clicks "Confirm & Publish", it resumes the hook with { type: "confirm" }. The workflow publishes the draft and exits the loop.

Both routes respond immediately to Slack (within the 3-second window). The actual processing happens asynchronously in the durable workflow.

Step 5: Schedule it with a daisy-chain

The summary workflow runs on demand via /api/trigger, but you want it to run automatically every week. You could put sleep("7d") in a while(true) loop inside a workflow. The problem: that workflow is pinned to the deployment it started on. Ship new code on Tuesday, and the sleeping workflow still runs last week's version when it wakes up on Friday.

Instead, each scheduler run does its work, sleeps, then starts the next run of itself with deploymentId: "latest". The current run ends. The next one picks up whatever code is currently deployed.

import { sleep } from "workflow";
import { start } from "workflow/api";
import { weeklySummaryWorkflow } from "./weekly-summary";
import { config, type Config } from "../config";
async function triggerSummary(cfg: Config) {
"use step";
const run = await start(weeklySummaryWorkflow, [cfg], {
deploymentId: "latest",
});
return run.runId;
}
async function startNextRun(cfg: Config) {
"use step";
const run = await start(schedulerWorkflow, [cfg], {
deploymentId: "latest",
});
return run.runId;
}
export async function schedulerWorkflow(cfg: Config) {
"use workflow";
// 1. Kick off the weekly summary
await triggerSummary(cfg);
// 2. Sleep for 1 week
await sleep(cfg.scheduleInterval);
// 3. Start the next run on the latest deployment
await startNextRun(cfg);
}

deploymentId: "latest" appears in both start() calls. When the scheduler wakes from its 7-day sleep and calls startNextRun, the new workflow run executes on whatever code is currently deployed — not the code from a week ago. The same applies to triggerSummary: the weekly summary itself also runs on the latest deployment.

Ship bug fixes, add new tools to the agent, change the summary format — the next scheduled run picks up the changes automatically. No need to restart the scheduler after each deploy.

deploymentId: "latest" and function naming

Since each run references schedulerWorkflow by its exported function binding, renaming the function or changing its argument types breaks the chain. The next startNextRun call would try to start a function that no longer exists under that name. Keep the function name and signature stable, or start a new chain after renaming.

Trigger routes

Two routes: one to start the scheduler (which then runs weekly on its own), and one to trigger a single summary without the scheduler.

import { start } from "workflow/api";
import { schedulerWorkflow } from "@/lib/workflows/scheduler";
import { config } from "@/lib/config";
export async function POST() {
const run = await start(schedulerWorkflow, [config]);
return Response.json({ runId: run.runId });
}

Start the dev server (npm run dev), then kick off the scheduler:

curl -X POST http://localhost:3000/api/start-scheduler

Or trigger a single summary without the scheduler:

curl -X POST http://localhost:3000/api/trigger

The workflow gathers data, the agent writes a draft, posts it to Slack, and waits. Reply in the thread with feedback. The agent refines. Click "Confirm & Publish" when it looks good. Open npx workflow web to watch steps execute in real time.

Next steps

This guide covered the core loop: gather data with step functions, draft with a DurableAgent, refine with iterable hooks, connect Slack events via deterministic tokens, and schedule it all with a daisy-chained workflow. Check out the full implementation here: workflow-dogfooding

From here, you could:

  • Give the agent more tools (Slack search, GitHub issue details, channel auto-join) to improve research depth
  • Track which sources the agent accessed and display them in the Slack message for transparency
  • Add a Slack modal for manual editing alongside the AI-powered refinement
  • Wire up observability with Datadog metrics and workflow run links
  • Add auto-expiry to draft threads so abandoned drafts don't linger forever — race the hook loop against a sleep timer and call hook.dispose() when time runs out

Was this helpful?

supported.