Make Chat SDK bot sessions durable — one workflow run per conversation thread, with hooks bridging inbound platform events into long-running agent logic.
Chat SDK is a unified TypeScript SDK for building bots across Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. Write the bot once, deploy to every platform. It handles webhook verification, event normalization, subscriptions, and cross-platform features like 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
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 for explicit child-run and handoff patterns using deploymentId: "latest".
The rest of this page covers the integration pattern. For a full Slack + Next.js + Redis walkthrough, see the Durable chat sessions guide on chat-sdk.dev.
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:
- Inbound — Chat SDK handlers decide whether to
start(workflow, [thread, message])orresumeHook(runId, { message }). TherunIdlives 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 — adapter packages use Node-only modules that aren't available in the workflow sandbox.
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)
Because the session is a workflow run, its history is recoverable from the event log — no separate message store to keep in sync.
Three files. The bot definition is separate from the workflow so adapter packages stay out of the workflow sandbox.
- Thread state stores the
runId. Chat SDK's state adapter (Redis, Postgres, memory) holds{ runId }per thread. That's the only piece of glue between the two SDKs. - First mention →
start(). Handler serializesthread+messagewithtoJSON(), passes them throughstart(durableChatSession, [payload]), stashes the returnedrunIdin thread state. - Subsequent messages →
resumeHook(). Handler looks up therunId, serializes the new message, and resumes the workflow's hook. The workflow picks up on the nextawait hookiteration. - Workflow posts back via steps. All Chat SDK side effects (
thread.post,thread.subscribe,thread.setState) happen inside"use step"helpers that dynamically import the bot. This keeps adapter packages outside the workflow sandbox. - Session ends — two ways. The workflow returns normally (user said
done, approval granted, etc.), or the workflow throws. Either way the run completes; the next inbound message with the stalerunIdfalls through tostartSession().
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.
Because the session is just a workflow, everything else from the cookbook composes naturally:
- Stream AI SDK responses into the thread. Use the AI SDK integration pattern inside a step, then pass
result.fullStreamtothread.post()— Chat SDK handles platform-specific streaming (Slack edit-in-place, Telegram message-per-chunk, etc.). - Give the bot a sandbox. Combine with the Sandbox integration: 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, resumeapprovalHookfrombot.onAction(...). - Scheduled follow-ups.
sleep("24h")before a proactive check-in. Surviving restarts is free.
Don't import the bot at the top of workflow files
Adapter packages (@chat-adapter/slack, @chat-adapter/telegram, etc.) 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 runIds
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.
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 and Sandbox session patterns.
Platform timeouts are separate from workflow timeouts
Slack wants a 200 within 3 seconds. The webhook handler returns immediately after resumeHook (which is fast) — the workflow then runs in the background and posts back via thread.post. Don't try to await the whole turn inside the webhook handler; that's what breaks in the naive integration.
Chat/Thread/Message— Chat SDK primitives.toJSON()/fromJSON()/reviverare the serialization layer.start()— start a new session workflow. Store the returnedrunIdin thread state.resumeHook()— forward a new platform message to the running workflow.getRun()—run.existsbefore resuming, to detect stalerunIds.defineHook()— per-turn suspension point inside the workflow.registerSingleton()— makes the bot resolvable from inside step functions.










