> ## Documentation Index
> Fetch the complete documentation index at: https://mintlify.com/vercel/workflow/llms.txt
> Use this file to discover all available pages before exploring further.

# Chat Session Modeling

> Model chat sessions at different architectural layers to control state ownership and handle interruptions.

Chat sessions in AI agents can be modeled at different layers of your architecture. The choice affects state ownership and how you handle interruptions and reconnections.

While there are many ways to model chat sessions, the two most common categories are single-turn and multi-turn.

## Single-Turn Workflows

Each user message triggers a new workflow run. The client or API route owns the conversation history and sends the full message array with each request.

<Tabs items={['Workflow', 'API Route', 'Client']}>
  <Tab value="Workflow">
    ```typescript title="workflows/chat/index.ts" lineNumbers theme={null}
    import { DurableAgent } from "@workflow/ai/agent";
    import { getWritable } from "workflow";
    import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "./steps/tools";
    import { convertToModelMessages, type UIMessage, type UIMessageChunk } from "ai";

    export async function chat(messages: UIMessage[]) {
      "use workflow";

      const writable = getWritable<UIMessageChunk>();

      const agent = new DurableAgent({
        model: "anthropic/claude-opus",
        system: FLIGHT_ASSISTANT_PROMPT,
        tools: flightBookingTools,
      });

      await agent.stream({
        messages: convertToModelMessages(messages), // Full history from client
        writable,
      });
    }
    ```
  </Tab>

  <Tab value="API Route">
    ```typescript title="app/api/chat/route.ts" lineNumbers theme={null}
    import { createUIMessageStreamResponse, type UIMessage } from "ai";
    import { start } from "workflow/api";
    import { chat } from "@/workflows/chat";

    export async function POST(req: Request) {
      const { messages }: { messages: UIMessage[] } = await req.json();

      const run = await start(chat, [messages]);

      return createUIMessageStreamResponse({
        stream: run.readable,
        headers: {
          "x-workflow-run-id": run.runId, // For stream reconnection
        },
      });
    }
    ```
  </Tab>

  <Tab value="Client">
    Chat messages are typically stored in a database. In this example, we fetch existing messages and persist new ones:

    ```typescript title="app/chats/[id]/page.tsx" lineNumbers theme={null}
    "use client";

    import { useChat } from "@ai-sdk/react";
    import { WorkflowChatTransport } from "@workflow/ai";
    import { useParams } from "next/navigation";
    import { useMemo } from "react";

    async function getMessages(sessionId: string) {
      const res = await fetch(`/api/chats/${sessionId}/messages`);
      return res.json();
    }

    export function Chat({ initialMessages }) {
      const { id: sessionId } = useParams<{ id: string }>();

      const transport = useMemo(
        () =>
          new WorkflowChatTransport({
            api: "/api/chat",
            onChatEnd: async () => {
              // Persist the updated messages to the chat session
              await fetch(`/api/chats/${sessionId}/messages`, {
                method: "PUT",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ messages }),
              });
            },
          }),
        [sessionId]
      );

      const { messages, input, handleInputChange, handleSubmit } = useChat({
        initialMessages, // Loaded via getMessages(sessionId)
        transport,
      });

      return (
        <form onSubmit={handleSubmit}>
          {/* ... render messages ... */}
          <input value={input} onChange={handleInputChange} />
        </form>
      );
    }
    ```
  </Tab>
</Tabs>

In this pattern, the client owns conversation state, with the latest turn managed by the AI SDK's `useChat`, and past turns persisted to a user-managed database.

Persisting turns is usually done through either:

* A step on the workflow that runs after `agent.stream()` and takes the message history from the agent return value
* A hook on `useChat` in the client that calls an API to persist state
* The resumable stream attached to the workflow (see [Resumable Streams](/ai/resumable-streams))

## Multi-Turn Workflows

A single workflow handles the entire conversation session across multiple turns, and owns the current conversation state. The clients/API routes inject new messages via hooks. The workflow run ID serves as the session identifier.

<Tabs items={['Workflow', 'API Routes', 'Hook Definition', 'Client Hook']}>
  <Tab value="Workflow">
    ```typescript title="workflows/chat/index.ts" lineNumbers theme={null}
    import {
      convertToModelMessages,
      type UIMessageChunk,
      type UIMessage,
      type ModelMessage,
    } from "ai";
    import { DurableAgent } from "@workflow/ai/agent";
    import { getWritable, getWorkflowMetadata } from "workflow";
    import { chatMessageHook } from "./hooks/chat-message";
    import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "./steps/tools";
    import { writeUserMessageMarker, writeStreamClose } from "./steps/writer";

    export async function chat(initialMessages: UIMessage[]) {
      "use workflow";

      const { workflowRunId: runId } = getWorkflowMetadata();
      const writable = getWritable<UIMessageChunk>();
      const messages: ModelMessage[] = convertToModelMessages(initialMessages);

      // Write markers for initial user messages (for replay)
      for (const msg of initialMessages) {
        if (msg.role === "user") {
          const text = msg.parts.filter((p) => p.type === "text").map((p) => p.text).join("");
          if (text) await writeUserMessageMarker(writable, text, msg.id);
        }
      }

      const agent = new DurableAgent({
        model: "anthropic/claude-opus",
        system: FLIGHT_ASSISTANT_PROMPT,
        tools: flightBookingTools,
      });

      // Use run ID as the hook token for easy resumption
      const hook = chatMessageHook.create({ token: runId });
      let turnNumber = 0;

      while (true) {
        turnNumber++;
        const result = await agent.stream({
          messages,
          writable,
          preventClose: true, // Keep stream open for follow-ups
          sendStart: turnNumber === 1,
          sendFinish: false,
        });
        messages.push(...result.messages.slice(messages.length));

        // Wait for next user message via hook
        const { message: followUp } = await hook;
        if (followUp === "/done") break;

        // Write marker and add to messages
        const followUpId = `user-${runId}-${turnNumber}`;
        await writeUserMessageMarker(writable, followUp, followUpId);
        messages.push({ role: "user", content: followUp });
      }

      await writeStreamClose(writable);
      return { messages };
    }
    ```

    The `writeUserMessageMarker` helper writes a `data-workflow` chunk to mark user turns:

    ```typescript title="workflows/chat/steps/writer.ts" lineNumbers theme={null}
    import type { UIMessageChunk } from "ai";

    export async function writeUserMessageMarker(
      writable: WritableStream<UIMessageChunk>,
      content: string,
      messageId: string
    ) {
      "use step";
      const writer = writable.getWriter();
      try {
        await writer.write({
          type: "data-workflow",
          data: { type: "user-message", id: messageId, content, timestamp: Date.now() },
        } as UIMessageChunk);
      } finally {
        writer.releaseLock();
      }
    }

    export async function writeStreamClose(writable: WritableStream<UIMessageChunk>) {
      "use step";
      const writer = writable.getWriter();
      await writer.write({ type: "finish" });
      await writer.close();
    }
    ```
  </Tab>

  <Tab value="API Routes">
    Three endpoints: start a session, send follow-up messages, and reconnect to the stream.

    ```typescript title="app/api/chat/route.ts" lineNumbers theme={null}
    import { createUIMessageStreamResponse, type UIMessage } from "ai";
    import { start } from "workflow/api";
    import { chat } from "@/workflows/chat";

    export async function POST(req: Request) {
      const { initialMessage }: { initialMessage: UIMessage } = await req.json();

      const run = await start(chat, [[initialMessage]]);

      return createUIMessageStreamResponse({
        stream: run.readable,
        headers: {
          "x-workflow-run-id": run.runId, // For follow-ups and reconnection
        },
      });
    }
    ```

    ```typescript title="app/api/chat/[id]/route.ts" lineNumbers theme={null}
    import { chatMessageHook } from "@/workflows/chat/hooks/chat-message";

    export async function POST(
      req: Request,
      { params }: { params: Promise<{ id: string }> }
    ) {
      const { id: runId } = await params;
      const { message } = await req.json();

      // Resume the hook using the workflow run ID
      await chatMessageHook.resume(runId, { message });

      return Response.json({ success: true });
    }
    ```

    ```typescript title="app/api/chat/[id]/stream/route.ts" lineNumbers theme={null}
    import { createUIMessageStreamResponse } from "ai";
    import { getRun } from "workflow/api";

    export async function GET(
      request: Request,
      { params }: { params: Promise<{ id: string }> }
    ) {
      const { id } = await params;
      const { searchParams } = new URL(request.url);
      const startIndex = searchParams.get("startIndex");

      const run = getRun(id);
      const stream = run.getReadable({
        startIndex: startIndex ? parseInt(startIndex, 10) : undefined,
      });

      return createUIMessageStreamResponse({ stream });
    }
    ```
  </Tab>

  <Tab value="Hook Definition">
    ```typescript title="workflows/chat/hooks/chat-message.ts" lineNumbers theme={null}
    import { defineHook } from "workflow";
    import { z } from "zod";

    export const chatMessageHook = defineHook({
      schema: z.object({
        message: z.string(),
      }),
    });
    ```
  </Tab>

  <Tab value="Client Hook">
    A custom hook wraps `useChat` to manage the multi-turn session:

    ```typescript title="hooks/use-multi-turn-chat.ts" lineNumbers theme={null}
    "use client";

    import type { UIMessage } from "ai";
    import { useChat } from "@ai-sdk/react";
    import { WorkflowChatTransport } from "@workflow/ai";
    import { useState, useCallback, useMemo, useEffect } from "react";

    const STORAGE_KEY = "workflow-run-id";

    interface UserMessageData {
      type: "user-message";
      id: string;
      content: string;
      timestamp: number;
    }

    export function useMultiTurnChat() {
      const [runId, setRunId] = useState<string | null>(null);
      const [shouldResume, setShouldResume] = useState(false);

      // Check for existing session on mount
      useEffect(() => {
        const storedRunId = localStorage.getItem(STORAGE_KEY);
        if (storedRunId) {
          setRunId(storedRunId);
          setShouldResume(true);
        }
      }, []);

      const transport = useMemo(
        () =>
          new WorkflowChatTransport({
            api: "/api/chat",
            onChatSendMessage: (response) => {
              const workflowRunId = response.headers.get("x-workflow-run-id");
              if (workflowRunId) {
                setRunId(workflowRunId);
                localStorage.setItem(STORAGE_KEY, workflowRunId);
              }
            },
            onChatEnd: () => {
              setRunId(null);
              localStorage.removeItem(STORAGE_KEY);
            },
            prepareReconnectToStreamRequest: ({ api, ...rest }) => {
              const storedRunId = localStorage.getItem(STORAGE_KEY);
              if (!storedRunId) throw new Error("No active session");
              return { ...rest, api: `/api/chat/${storedRunId}/stream` };
            },
          }),
        []
      );

      const { messages: rawMessages, sendMessage: baseSendMessage, status, stop, setMessages } =
        useChat({ resume: shouldResume, transport });

      // Reconstruct conversation order from stream markers
      const messages = useMemo(() => {
        const result: UIMessage[] = [];
        const seenContent = new Set<string>();

        for (const msg of rawMessages) {
          if (msg.role === "user") {
            const text = msg.parts.filter((p) => p.type === "text").map((p) => p.text).join("");
            if (text) seenContent.add(text);
          }
        }

        for (const msg of rawMessages) {
          if (msg.role === "user") {
            result.push(msg);
            continue;
          }

          if (msg.role === "assistant") {
            let currentParts: typeof msg.parts = [];
            let partIndex = 0;

            for (const part of msg.parts) {
              if (part.type === "data-workflow" && "data" in part) {
                const data = part.data as UserMessageData;
                if (data?.type === "user-message") {
                  // Flush accumulated assistant parts
                  if (currentParts.length > 0) {
                    result.push({ ...msg, id: `${msg.id}-${partIndex++}`, parts: currentParts });
                    currentParts = [];
                  }
                  // Add user message if not duplicate
                  if (!seenContent.has(data.content)) {
                    seenContent.add(data.content);
                    result.push({ id: data.id, role: "user", parts: [{ type: "text", text: data.content }] });
                  }
                  continue;
                }
              }
              currentParts.push(part);
            }

            if (currentParts.length > 0) {
              result.push({ ...msg, id: partIndex > 0 ? `${msg.id}-${partIndex}` : msg.id, parts: currentParts });
            }
          }
        }
        return result;
      }, [rawMessages]);

      const sendMessage = useCallback(
        async (text: string) => {
          if (runId) {
            // Follow-up: send via hook resumption
            await fetch(`/api/chat/${runId}`, {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({ message: text }),
            });
          } else {
            // First message: start new workflow
            await baseSendMessage({ text, metadata: { createdAt: Date.now() } });
          }
        },
        [runId, baseSendMessage]
      );

      const endSession = useCallback(async () => {
        if (runId) {
          await fetch(`/api/chat/${runId}`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ message: "/done" }),
          });
        }
        setRunId(null);
        setShouldResume(false);
        localStorage.removeItem(STORAGE_KEY);
        setMessages([]);
      }, [runId, setMessages]);

      return { messages, status, runId, sendMessage, endSession, stop };
    }
    ```
  </Tab>
</Tabs>

In this pattern, the workflow owns the entire conversation session. All messages are persisted in the workflow, and follow-up messages are injected via hooks. The workflow writes **user message markers** to the stream using `data-workflow` chunks, which allows the client to reconstruct the full conversation in the correct order when replaying the stream.

## Choosing a Pattern

| Consideration | Single-Turn | Multi-Turn |
| - | - | - |
| State ownership | Client or API route | Workflow |
| Message injection from backend | Requires stitching together runs | Native via hooks |
| Workflow complexity | Lower | Higher |
| Workflow time horizon | Minutes | Hours to indefinitely |
| Observability scope | Per-turn traces | Full session traces |

**Multi-turn is recommended for most production use-cases.** If you're starting fresh, go with multi-turn. It's more flexible and grows with your requirements. You don't need to maintain the chat history yourself and can offload all that to the workflow's built-in persistence.

**Single-turn works well when adapting existing architectures.** If you already have a system for managing message state, and want to adopt durable agents incrementally, single-turn workflows slot in with minimal changes.

## Related Documentation

* [Building Durable AI Agents](/ai/overview) - Foundation guide for durable agents
* [Message Queueing](/ai/message-queueing) - Queueing messages during tool execution
* [`defineHook()` API Reference](/api-reference/workflow/define-hook) - Hook configuration options
* [`DurableAgent` API Reference](/api-reference/workflow-ai/durable-agent) - Full API documentation


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.