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