Skip to main content
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.
workflows/chat/index.ts
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,
  });
}
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)

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.
workflows/chat/index.ts
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:
workflows/chat/steps/writer.ts
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();
}
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

ConsiderationSingle-TurnMulti-Turn
State ownershipClient or API routeWorkflow
Message injection from backendRequires stitching together runsNative via hooks
Workflow complexityLowerHigher
Workflow time horizonMinutesHours to indefinitely
Observability scopePer-turn tracesFull 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.

Build docs developers (and LLMs) love