# Building a Slack agent with durable workflows

**Author:** John Lindquist, Pranay Prakash

---

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](https://useworkflow.dev/docs/getting-started/next) installed
  
- A Slack app with a bot token (`xoxb-...`) and scopes: `channels:history`, `channels:read`, `chat:write`, `users:read`
  
- A [GitHub personal access token](https://github.com/settings/tokens) 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. 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    * * * ### Build with [Chat SDK](https://chat-sdk.dev/)

---

[View full KB sitemap](/kb/sitemap.md)
