---
title: Durable chat sessions with Next.js, Workflow, and Redis
description: This guide walks through combining Chat SDK and Workflow so a chat thread can survive restarts, wait for follow-up messages, and keep its session state in Redis.
type: guide
prerequisites: []
related:
  - /docs/guides/slack-nextjs
  - /docs/handling-events
  - /docs/streaming
  - /docs/api/thread
  - /docs/api/chat
---

Chat SDK and Workflow solve different parts of the same problem.

Chat SDK normalizes incoming platform events into `thread` and `message` objects and gives you a consistent way to reply. Workflow gives you durable execution so a session can wait for the next turn without holding a request open or losing state on restart.

This guide uses Slack and Next.js for a concrete example, but the same pattern works with any Chat SDK adapter.

## Prerequisites

- Node.js 18+
- [pnpm](https://pnpm.io) (or npm/yarn)
- A Next.js App Router project
- A Slack workspace where you can install apps
- A Redis instance for Chat SDK state

<Callout type="info">
  If you still need the Slack app manifest and webhook setup, start with [Slack bot with Next.js and Redis](/docs/guides/slack-nextjs), then come back here to add Workflow.
</Callout>

## Install the dependencies

Install Chat SDK, the Slack adapter, Redis state, and Workflow:

```sh title="Terminal"
pnpm add chat @chat-adapter/slack @chat-adapter/state-redis workflow
```

## Enable Workflow in Next.js

Wrap your Next.js config with `withWorkflow()` so `"use workflow"` and `"use step"` directives are compiled correctly:

```typescript title="next.config.ts" lineNumbers
import { withWorkflow } from "workflow/next";
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // ...your existing config
};

export default withWorkflow(nextConfig);
```

<Callout type="info">
  If your app uses `proxy.ts`, exclude `.well-known/workflow/` from the matcher so Workflow's internal routes are not intercepted.
</Callout>

## Create the Chat instance

Create a bot instance exactly as you would for a normal Chat SDK app, but keep the bot definition separate from the workflow code:

```typescript title="lib/bot.ts" lineNumbers
import { createRedisState } from "@chat-adapter/state-redis";
import { createSlackAdapter } from "@chat-adapter/slack";
import { Chat } from "chat";

const adapters = {
  slack: createSlackAdapter(),
};

export interface ThreadState {
  runId?: string;
}

export const bot = new Chat<typeof adapters, ThreadState>({
  userName: "durable-bot",
  adapters,
  state: createRedisState(),
}).registerSingleton();
```

`runId` will store the active workflow run for each subscribed thread.

`registerSingleton()` matters here because Workflow may deserialize `Thread` objects again inside `"use step"` functions, and Chat SDK needs a registered singleton to resolve the adapter and state layer for those thread instances.

## Define a hook payload type

Workflow hooks are how follow-up messages get injected back into a running session. Define the payload type once so both the workflow and the webhook side stay in sync:

```typescript title="workflows/chat-turn-hook.ts" lineNumbers
import type { SerializedMessage } from "chat";

export type ChatTurnPayload = {
  message: SerializedMessage;
};
```

## Create the durable session workflow

The workflow receives the serialized thread and first message, restores them with `reviver`, and then keeps waiting for more turns through the hook.

The important detail is that the workflow only orchestrates. Chat SDK side effects such as `post()`, `unsubscribe()`, and `setState()` stay inside step helpers:

```typescript title="workflows/durable-chat-session.ts" lineNumbers
import { Message, reviver, type Thread } from "chat";
import { createHook, getWorkflowMetadata } from "workflow";
import type { ThreadState } from "@/lib/bot";
import type { ChatTurnPayload } from "@/workflows/chat-turn-hook";

async function postAssistantMessage(
  thread: Thread<ThreadState>,
  text: string
) {
  "use step";

  const { bot } = await import("@/lib/bot");
  await bot.initialize();
  await thread.post(text);
}

async function closeSession(thread: Thread<ThreadState>) {
  "use step";

  const { bot } = await import("@/lib/bot");
  await bot.initialize();
  await thread.post("Session closed.");
  await thread.unsubscribe();
  await thread.setState({}, { replace: true });
}

async function runTurn(text: string) {
  "use step";

  // Replace this with AI SDK calls, database work, or other business logic.
  return `You said: ${text}`;
}

async function processMessage(
  thread: Thread<ThreadState>,
  message: Message
) {
  const text = message.text.trim();

  if (text.toLowerCase() === "done") {
    await closeSession(thread);
    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 {
    thread: Thread<ThreadState>;
    message: Message;
  };

  using hook = createHook<ChatTurnPayload>({ token: workflowRunId });

  await postAssistantMessage(
    thread,
    "Durable session started. Reply in this thread and send `done` when you want to stop."
  );

  const shouldContinue = await processMessage(thread, message);
  if (!shouldContinue) {
    return;
  }

  for await (const event of hook) {
    const nextMessage = Message.fromJSON(event.message);

    const keepRunning = await processMessage(thread, nextMessage);
    if (!keepRunning) {
      return;
    }
  }
}
```

<Callout type="info">
  The `using` keyword requires TypeScript 5.2+ with `"lib": ["esnext.disposable"]` in your `tsconfig.json`. If you are on an older version, call `hook.dispose()` manually when the session ends.
</Callout>

<Callout type="warn">
  Do not import `bot` at the top level of a workflow file. Adapter packages depend on Node.js modules that are not available in the workflow sandbox. Use the standalone `reviver` for deserialization and import `bot` dynamically inside `"use step"` functions where Node.js modules are available.
</Callout>

This is the core integration:

- `thread.toJSON()` and `message.toJSON()` cross the workflow boundary safely
- `reviver` restores real Chat SDK objects inside the workflow without pulling in adapter dependencies
- `registerSingleton()` is called in `lib/bot.ts` and the singleton is available inside step functions when `bot` is dynamically imported
- `createHook<ChatTurnPayload>({ token: workflowRunId })` makes the workflow run itself the session identifier
- `runTurn()`, `postAssistantMessage()`, and `closeSession()` are steps, so adapter and state side effects stay outside the workflow sandbox

## Register Chat SDK event handlers

Create a small side-effect module that decides whether to start a new workflow or resume the existing one:

```typescript title="lib/chat-session-handlers.ts" lineNumbers
import { type Message, type Thread } from "chat";
import { 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, [
    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;

  if (!state?.runId) {
    await startSession(thread, message);
    return;
  }

  await resumeHook<ChatTurnPayload>(state.runId, {
    message: message.toJSON(),
  });
}

bot.onNewMention(async (thread, message) => {
  await thread.subscribe();
  await routeTurn(thread, message);
});

bot.onSubscribedMessage(async (thread, message) => {
  await routeTurn(thread, message);
});
```

On the first mention, the handler subscribes the thread and starts a workflow. Every later message resumes the existing run by sending the serialized message to the hook.

<Callout type="info">
  In production, catch `resumeHook()` failures, clear stale `runId` values, and start a new session if the old workflow has already ended.
</Callout>

## Create the webhook route

Import the side-effect module once so the handlers are registered before the webhook runs:

```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(
  request: Request,
  context: RouteContext<"/api/webhooks/[platform]">
) {
  const { platform } = await context.params;

  const handler = bot.webhooks[platform as Platform];
  if (!handler) {
    return new Response(`Unknown platform: ${platform}`, { status: 404 });
  }

  return handler(request, {
    waitUntil: (task) => after(() => task),
  });
}
```

## Replace the step with AI

The workflow pattern stays the same if you want AI responses. Replace `runTurn()` with a step that calls AI SDK:

```typescript title="workflows/durable-chat-session.ts" lineNumbers
import { anthropic } from "@ai-sdk/anthropic";
import { generateText } from "ai";

async function runTurn(text: string) {
  "use step";

  const { text: reply } = await generateText({
    model: anthropic("claude-sonnet-4-5"),
    system: "You are a helpful assistant in a chat thread.",
    prompt: text,
  });

  return reply;
}
```

Install the extra packages if you use this version:

```sh title="Terminal"
pnpm add ai @ai-sdk/anthropic
```

## How the pattern works

1. A user @mentions the bot in a thread.
2. Chat SDK subscribes the thread and starts `durableChatSession()`.
3. The handler stores the workflow `runId` in Chat SDK thread state.
4. Follow-up messages call `resumeHook(runId, ...)` instead of starting a new run.
5. The workflow keeps ownership of the session until the user sends `done` or you end it some other way.

This gives you a durable session boundary without moving platform-specific webhook code into your workflow layer.

From here you can add:

- inactivity timeouts with Workflow `sleep()`
- escalation or approval pauses with additional hooks
- AI-generated replies, tool calls, or human handoffs inside `"use step"` functions

## Next steps

- [Slack bot with Next.js and Redis](/docs/guides/slack-nextjs) — Slack app setup and basic webhook wiring
- [Handling Events](/docs/handling-events) — Mentions, subscribed messages, and routing behavior
- [Streaming](/docs/streaming) — Stream AI SDK responses directly to chat platforms
- [Thread API](/docs/api/thread) — `thread.toJSON()`, `thread.setState()`, and other thread primitives
- [Chat API](/docs/api/chat) — `reviver`, initialization, and webhook access
