UNPKG

workflow

Version:

Workflow SDK - Build durable, resilient, and observable workflows

321 lines (236 loc) • 17.1 kB
--- title: Chat SDK description: Make Chat SDK bot sessions durable, with one workflow run per conversation thread and hooks bridging inbound platform events into long-running agent logic. type: guide summary: Chat SDK normalizes Slack, Teams, Discord, Telegram, and similar platforms into one thread and message model. Workflow SDK gives each thread a durable run that owns multi-turn state, can sleep for hours, and survives restarts. related: - /docs/cookbook/integrations/ai-sdk - /docs/cookbook/integrations/sandbox - /docs/api-reference/workflow/define-hook - /docs/api-reference/workflow-api/start - /docs/api-reference/workflow-api/get-run --- <CopyPrompt text="Make this Chat SDK bot durable with Workflow SDK. Install/use `workflow`. Create one exported workflow function with &quot;use workflow&quot; per chat thread. Store the Chat SDK thread ID, Workflow run ID, and any serialized conversation state in the project data store. Use `defineHook()` from `workflow` for incoming turns and call `resumeHook()` from `workflow/api` from the Chat SDK webhook or message handler. Put provider calls, database writes, and outbound platform messages in &quot;use step&quot; helper functions. Start a new run with `start(workflowFn, [initialThreadState])` when no run exists, otherwise resume the existing hook. Use `getRun(runId)` for status, cancellation, or stream reads. Verify first message, follow-up message, restart/reconnect, duplicate webhook, and failed-send retry behavior." /> [Chat SDK](https://chat-sdk.dev/) is a unified TypeScript SDK for building bots across Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. A single bot can support each platform. Chat SDK handles webhook verification, event normalization, subscriptions, and cross-platform features such as cards and modals. Workflow SDK complements it by making bot **sessions** durable. Each conversation thread maps to a long-running workflow run that: - Owns multi-turn state in the durable event log instead of Redis-by-hand bookkeeping - Can `sleep()` for hours or days waiting for a user reply, an approval, or a scheduled follow-up - Survives deploys, cold starts, and crashes: the session picks up from the last step on replay - Receives follow-up messages via hooks, so the bot stays responsive while the workflow is still running <Callout type="info"> One thread mapped to one workflow run also means the thread stays on the deployment that started it. For channels where each message should use newer code, see [Versioning](/docs/foundations/versioning) for explicit child-run and handoff patterns using `deploymentId: "latest"`. </Callout> The rest of this page covers the integration pattern. For a full Slack + Next.js + Redis walkthrough, see the [Durable chat sessions guide](https://chat-sdk.dev/docs/guides/durable-chat-sessions-nextjs) on chat-sdk.dev. ## How it fits together Chat SDK owns the edge: webhook verification, event routing, `thread.post()` / `thread.stream()`. Workflow owns the session: state, loops, sleeps, retries. They meet at exactly two points: ```mermaid flowchart TD A["Platform webhook"] --> B["Chat SDK event handler<br/>(onNewMention, onSubscribedMessage, …)"] B -->|"no runId in thread state"| C["start(durableChatSession, …)"] B -->|"runId in thread state"| D["resumeHook(runId, { message })"] C --> E["Workflow run (durable)<br/>one per thread; suspends between turns"] D --> E E --> F["&quot;use step&quot; helpers<br/>thread.post(), thread.subscribe(), thread.setState(), …"] ``` - **Inbound**: Chat SDK handlers decide whether to `start(workflow, [thread, message])` or `resumeHook(runId, { message })`. The `runId` lives in Chat SDK's thread state (Redis, Postgres, or any state adapter). - **Outbound**: the workflow calls Chat SDK APIs (`thread.post()`, `thread.subscribe()`, `thread.setState()`) from inside step functions. Never from the top level of a workflow file, since adapter packages use Node-only modules that aren't available in the workflow sandbox. ## Why Workflow + Chat SDK Without Workflow, a long-running bot session usually means one of: - Holding a webhook request open while the agent runs (doesn't survive restarts, blows past platform timeouts) - Writing session state to Redis manually, plus a scheduler for timeouts and retries, plus custom reconnection logic Workflow replaces all of that with a single durable function. The bot can: - Run a tool loop for minutes while the user watches typing indicators - Wait for a human approval in another thread before continuing - Schedule a follow-up message 24 hours later via `sleep("24h")` - Pause on sandbox snapshot, resume when the user sends the next command (see the [Sandbox integration](/docs/cookbook/integrations/sandbox)) Because the session *is* a workflow run, its history is recoverable from the event log, so there's no separate message store to keep in sync. ## The pattern: one thread = one workflow run This pattern uses three files. The bot definition is separate from the workflow so adapter packages stay out of the workflow sandbox. <TabsWithChildren tabs={["Bot Setup","Workflow","Event Handlers"]}> <TabContent order={1}> Register the `Chat` instance as a singleton so step functions can dynamically import it and resolve adapters + state: ```typescript title="lib/bot.ts" lineNumbers import { Chat } from "chat"; import { createSlackAdapter } from "@chat-adapter/slack"; import { createRedisState } from "@chat-adapter/state-redis"; const adapters = { slack: createSlackAdapter(), }; export interface ThreadState { runId?: string; // [!code highlight] } export const bot = new Chat<typeof adapters, ThreadState>({ userName: "durable-bot", adapters, state: createRedisState(), dedupeTtlMs: 600_000, }).registerSingleton(); // [!code highlight] ``` `registerSingleton()` is important: Chat SDK re-hydrates `Thread` objects inside step functions, and it needs a registered singleton to resolve adapters and state for those rehydrated instances. </TabContent> <TabContent order={2}> The workflow is a plain loop over a hook. It receives the serialized thread + first message from the handler, revives them via Chat SDK's standalone `reviver`, and every platform-side effect goes inside a `"use step"` helper: ```typescript title="workflows/durable-chat-session.ts" lineNumbers import { Message, reviver, type Thread } from "chat"; import { defineHook, getWorkflowMetadata } from "workflow"; import type { ThreadState } from "@/lib/bot"; // Hook payload lives in its own file so the webhook side can import it without // pulling in the workflow module. import type { ChatTurnPayload } from "@/workflows/chat-turn-hook"; const chatTurnHook = defineHook<ChatTurnPayload>(); // [!code highlight] async function postAssistantMessage( thread: Thread<ThreadState>, text: string ) { "use step"; // Dynamic import keeps adapter packages out of the workflow sandbox. const { bot } = await import("@/lib/bot"); // [!code highlight] await bot.initialize(); await thread.post(text); } async function runTurn(text: string) { "use step"; // Your AI SDK call, database lookup, tool loop, and other operations. return `You said: ${text}`; } async function handleMessage( thread: Thread<ThreadState>, message: Message ) { const text = message.text.trim(); if (text.toLowerCase() === "done") return false; const reply = await runTurn(text); await postAssistantMessage(thread, reply); return true; } export async function durableChatSession(payload: string) { "use workflow"; const { workflowRunId } = getWorkflowMetadata(); const { thread, message } = JSON.parse(payload, reviver) as { // [!code highlight] thread: Thread<ThreadState>; message: Message; }; const hook = chatTurnHook.create({ token: workflowRunId }); await postAssistantMessage(thread, "Session started. Reply here; send `done` to stop."); if (!(await handleMessage(thread, message))) return; // Each hook resumption is one turn. The workflow stays suspended between // messages: zero compute cost while idle. while (true) { const { message: nextRaw } = await hook; // [!code highlight] const next = Message.fromJSON(nextRaw); if (!(await handleMessage(thread, next))) return; } } ``` ```typescript title="workflows/chat-turn-hook.ts" lineNumbers import type { SerializedMessage } from "chat"; export type ChatTurnPayload = { message: SerializedMessage; }; ``` </TabContent> <TabContent order={3}> Handlers live outside the workflow file so adapter dependencies don't leak in. They decide whether to start a new workflow or resume an existing one, then store the `runId` in thread state: <Callout type="info"> If the platform can deliver the same first message concurrently, use a deterministic hook token derived from the thread ID so duplicate handlers route to the active chat session hook. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). </Callout> ```typescript title="lib/chat-session-handlers.ts" lineNumbers import type { Message, Thread } from "chat"; import { getRun, resumeHook, start } from "workflow/api"; import { bot, type ThreadState } from "@/lib/bot"; import { durableChatSession } from "@/workflows/durable-chat-session"; import type { ChatTurnPayload } from "@/workflows/chat-turn-hook"; async function startSession(thread: Thread<ThreadState>, message: Message) { const run = await start(durableChatSession, [ // [!code highlight] JSON.stringify({ thread: thread.toJSON(), message: message.toJSON(), }), ]); await thread.setState({ runId: run.runId }); } async function routeTurn(thread: Thread<ThreadState>, message: Message) { const state = await thread.state; // No run yet, or the previous run finished: start fresh. if (!state?.runId || !(await getRun(state.runId).exists)) { await startSession(thread, message); return; } try { await resumeHook<ChatTurnPayload>(state.runId, { // [!code highlight] message: message.toJSON(), }); } catch (err) { const msg = err instanceof Error ? err.message.toLowerCase() : ""; if (msg.includes("not found") || msg.includes("expired")) { // Stale runId: start a new session rather than dropping the message. await startSession(thread, message); return; } throw err; } } bot.onNewMention(async (thread, message) => { await thread.subscribe(); await routeTurn(thread, message); }); bot.onSubscribedMessage(async (thread, message) => { await routeTurn(thread, message); }); ``` Wire Chat SDK's webhook handler into a catch-all route. Importing `chat-session-handlers` for side effects registers the event handlers before the first webhook arrives: ```typescript title="app/api/webhooks/[platform]/route.ts" lineNumbers import "@/lib/chat-session-handlers"; import { after } from "next/server"; import { bot } from "@/lib/bot"; type Platform = keyof typeof bot.webhooks; export async function POST( req: Request, { params }: { params: Promise<{ platform: string }> } ) { const { platform } = await params; const handler = bot.webhooks[platform as Platform]; if (!handler) return new Response(`Unknown platform: ${platform}`, { status: 404 }); return handler(req, { waitUntil: (task) => after(() => task) }); // [!code highlight] } ``` </TabContent> </TabsWithChildren> ## How it works 1. **Thread state stores the `runId`**: Chat SDK's state adapter (Redis, Postgres, or memory) holds `{ runId }` per thread. This state connects the two SDKs. 2. **The first mention calls `start()`**: The handler serializes `thread` and `message` with `toJSON()`, passes them through `start(durableChatSession, [payload])`, and stores the returned `runId` in thread state. 3. **Subsequent messages call `resumeHook()`**: The handler looks up the `runId`, serializes the new message, and resumes the workflow's hook. The workflow continues on the next `await hook` iteration. 4. **The workflow posts through steps**: All Chat SDK side effects (`thread.post`, `thread.subscribe`, and `thread.setState`) happen inside `"use step"` helpers that dynamically import the bot. This keeps adapter packages outside the workflow sandbox. 5. **The session ends in two ways**: The workflow returns normally when the user sends `done` or an approval is granted, or the workflow throws. Either way, the run completes. The next inbound message with the stale `runId` falls through to `startSession()`. The workflow is fully durable between turns: `await hook` suspends with zero compute cost, and platform webhooks can fire from anywhere without concern for which server instance handled the previous turn. ## Extending the pattern Because the session is a workflow, everything else from the cookbook composes naturally: - **Stream AI SDK responses into the thread.** Use the [AI SDK integration](/docs/cookbook/integrations/ai-sdk) pattern inside a step, then pass `result.fullStream` to `thread.post()`. Chat SDK handles platform-specific streaming, including Slack edit-in-place and Telegram message-per-chunk. - **Give the bot a sandbox.** Combine with the [Sandbox integration](/docs/cookbook/integrations/sandbox): each thread gets its own persistent sandbox session, snapshots on idle, resumes on the next message. That's effectively a coding-agent bot. - **Human-in-the-loop approvals.** `Promise.race([hook, approvalHook])` inside the workflow, post buttons in the thread via [cards](https://chat-sdk.dev/docs/cards), resume `approvalHook` from `bot.onAction(...)`. - **Scheduled follow-ups.** Call `sleep("24h")` before a proactive check-in. The workflow preserves the timer across restarts. ## Pitfalls ### Don't import the bot at the top of workflow files Adapter packages such as `@chat-adapter/slack` and `@chat-adapter/telegram` depend on Node-only modules that aren't available in the workflow bundler's sandbox. Keep `import { bot } from "@/lib/bot"` inside `"use step"` functions with `await import(...)`. Use `reviver` from `chat` for deserialization inside the workflow: it's standalone and has no adapter dependencies. ### Register the bot as a singleton `new Chat({...}).registerSingleton()`. Chat SDK rehydrates `Thread` objects inside step functions via `reviver`, and it looks up adapters + state from the registered singleton. Without it, thread methods throw when called from step contexts. ### Hook payloads must be JSON-serializable `Message` and `Thread` have methods, so pass them through `.toJSON()` / `Message.fromJSON()` across the hook boundary. Define a `ChatTurnPayload` type in its own file so both the webhook handler (in the Node bundle) and the workflow (in the workflow sandbox) can share it without dragging in adapter code. ### Handle stale `runId`s A workflow run ends but its `runId` is still cached in thread state. The next message calls `resumeHook` on a dead run and throws `not found` / `expired`. Gate on `getRun(runId).exists` before resuming, or catch the error and fall through to `startSession`. Either way the user's message must not be dropped. ### Make first-message routing atomic Thread state is also the idempotency boundary for starting sessions. Back it with a state adapter or database operation that can atomically claim the thread before `startSession()` runs when duplicate sessions would be harmful. ### Keep the hook outside the loop One `chatTurnHook.create({ token: workflowRunId })` per workflow run, reused every iteration. Creating a new hook with the same token throws `HookConflictError`. This is the same rule as the [AI SDK](/docs/cookbook/integrations/ai-sdk) and [Sandbox](/docs/cookbook/integrations/sandbox) session patterns. ### Platform timeouts are separate from workflow timeouts Slack requires an HTTP 200 response within 3s. The webhook handler returns after `resumeHook`, then the workflow runs in the background and posts through `thread.post`. Don't `await` the whole turn inside the webhook handler because that synchronous integration exceeds the platform timeout. ## Key APIs - [`Chat`](https://chat-sdk.dev/docs/api/chat) / [`Thread`](https://chat-sdk.dev/docs/api/thread) / [`Message`](https://chat-sdk.dev/docs/api/message): Chat SDK primitives. `toJSON()` / `fromJSON()` / `reviver` are the serialization layer. - [`start()`](/docs/api-reference/workflow-api/start): start a new session workflow. Store the returned `runId` in thread state. - [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): forward a new platform message to the running workflow. - [`getRun()`](/docs/api-reference/workflow-api/get-run): `run.exists` before resuming, to detect stale `runId`s. - [`defineHook()`](/docs/api-reference/workflow/define-hook): per-turn suspension point inside the workflow. - [`registerSingleton()`](https://chat-sdk.dev/docs/api/chat): makes the bot resolvable from inside step functions. - [Idempotency](/docs/foundations/idempotency): protect duplicate-sensitive first messages and side effects.