---
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
---

# Chat SDK



<CopyPrompt text="Make this Chat SDK bot durable with Workflow SDK. Install/use `workflow`. Create one exported workflow function with &#x22;use workflow&#x22; 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 &#x22;use step&#x22; 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
  chart="flowchart TD
    A[&#x22;Platform webhook&#x22;] --> B[&#x22;Chat SDK event handler<br/>(onNewMention, onSubscribedMessage, …)&#x22;]
    B -->|&#x22;no runId in thread state&#x22;| C[&#x22;start(durableChatSession, …)&#x22;]
    B -->|&#x22;runId in thread state&#x22;| D[&#x22;resumeHook(runId, { message })&#x22;]
    C --> E[&#x22;Workflow run (durable)<br/>one per thread; suspends between turns&#x22;]
    D --> E
    E --> F[&#x22;&quot;use step&quot; helpers<br/>thread.post(), thread.subscribe(), thread.setState(), …&#x22;]"
/>

* **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](/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.

<Tabs items={['Bot Setup', 'Workflow', 'Event Handlers']}>
  <Tab value="Bot Setup">
    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.
  </Tab>

  <Tab value="Workflow">
    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;
    };
    ```
  </Tab>

  <Tab value="Event Handlers">
    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]
    }
    ```
  </Tab>
</Tabs>

## 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](/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](/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](/cookbook/integrations/ai-sdk) and [Sandbox](/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.


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)