AI Agents

Chat Session Modeling

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.

app/api/chat/workflow.ts
import { DurableAgent } from '@workflow/ai/agent';
import { getWritable } from 'workflow';
import type { ModelMessage, UIMessageChunk } from 'ai';

export async function chatWorkflow(messages: ModelMessage[]) {
  'use workflow';

  const writable = getWritable<UIMessageChunk>();

  const agent = new DurableAgent({
    model: 'anthropic/claude-sonnet-4',
    system: 'You are a helpful assistant.',
    tools: { /* ... */ },
  });

  const { messages: result } = await agent.stream({
    messages, // Full history from client
    writable,
  });

  return { messages: result };
}
app/api/chat/route.ts
import { createUIMessageStreamResponse, convertToModelMessages } from 'ai';
import { start } from 'workflow/api';
import { chatWorkflow } from './workflow';

export async function POST(req: Request) {
  const { messages } = await req.json();
  const modelMessages = convertToModelMessages(messages);

  const run = await start(chatWorkflow, [modelMessages]); 

  return createUIMessageStreamResponse({
    stream: run.readable,
  });
}

Chat messages need to be stored somewhere—typically a database. In this example, we assume a route like /chats/:id passes the session ID, allowing us to fetch existing messages and persist new ones.

app/chats/[id]/page.tsx
'use client';

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

// Fetch existing messages from your backend
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>
  );
}

In this pattern, the client owns conversation state, with the latest turn managed by the AI SDK's useChat, and past turns persisted to the backend. The current turn is either managed through the workflow by a resumable stream (see Resumable Streams), or a hook into useChat persists every new message to the backend, as messages come in.

This is the pattern used in the Building Durable AI Agents guide.

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.

app/api/chat/workflow.ts
import { DurableAgent } from '@workflow/ai/agent';
import { getWritable } from 'workflow';
import { chatMessageHook } from '@/ai/hooks/chat-message';
import type { ModelMessage, UIMessageChunk } from 'ai';

export async function chatWorkflow(threadId: string, initialMessage: string) {
  'use workflow';

  const writable = getWritable<UIMessageChunk>();
  const messages: ModelMessage[] = [{ role: 'user', content: initialMessage }];

  const agent = new DurableAgent({
    model: 'anthropic/claude-sonnet-4',
    system: 'You are a helpful assistant.',
    tools: { /* ... */ },
  });

  // Create hook with thread-specific token for resumption
  const hook = chatMessageHook.create({ token: `thread:${threadId}` }); 

  while (true) {
    // Process current messages
    const { messages: result } = await agent.stream({
      messages,
      writable,
      preventClose: true, // Keep stream open for follow-ups
    });
    messages.push(...result.slice(messages.length));

    // Wait for next user message
    const { message } = await hook; 
    if (message === '/done') break;

    messages.push({ role: 'user', content: message });
  }

  return { messages };
}

Two endpoints: one to start the session, one to send follow-up messages.

app/api/chat/route.ts
import { createUIMessageStreamResponse } from 'ai';
import { start } from 'workflow/api';
import { chatWorkflow } from './workflow';

export async function POST(req: Request) {
  const { threadId, message } = await req.json();

  const run = await start(chatWorkflow, [threadId, message]); 

  return createUIMessageStreamResponse({
    stream: run.readable,
  });
}
app/api/chat/[id]/route.ts
import { chatMessageHook } from '@/ai/hooks/chat-message';

export async function POST(req: Request) {
  const { message } = await req.json();
  const { id: threadId } = await params; 

  await chatMessageHook.resume(`thread:${threadId}`, { message }); 

  return Response.json({ success: true });
}
ai/hooks/chat-message.ts
import { defineHook } from 'workflow';
import { z } from 'zod';

export const chatMessageHook = defineHook({
  schema: z.object({
    message: z.string(),
  }),
});
hooks/use-multi-turn-chat.ts
'use client';

import { useChat } from '@ai-sdk/react'; 
import { WorkflowChatTransport } from '@workflow/ai'; 
import { useState, useCallback, useMemo } from 'react';

export function useMultiTurnChat() {
  const [threadId, setThreadId] = useState<string | null>(null);

  const transport = useMemo( 
    () =>
      new WorkflowChatTransport({ 
        api: '/api/chat', 
      }), 
    [] 
  ); 

  const {
    messages,
    sendMessage: sendInitialMessage, // Renamed from sendMessage
    ...chatProps
  } = useChat({ transport }); 

  const startSession = useCallback(
    async (message: string) => {
      const newThreadId = crypto.randomUUID();
      setThreadId(newThreadId);

      // Send initial message with threadId in body
      await sendInitialMessage(message, { 
        body: { threadId: newThreadId }, 
      }); 
    },
    [sendInitialMessage]
  );

  // Follow-up messages go through the hook resumption endpoint
  const sendMessage = useCallback(
    async (message: string) => {
      if (!threadId) return;

      await fetch(`/api/chat/${threadId}`, { 
        method: 'POST', 
        headers: { 'Content-Type': 'application/json' }, 
        body: JSON.stringify({ message }), 
      }); 
    },
    [threadId]
  );

  const endSession = useCallback(async () => {
    if (!threadId) return;
    await sendMessage('/done');
    setThreadId(null);
  }, [threadId, sendMessage]);

  return { messages, threadId, startSession, sendMessage, endSession, ...chatProps };
}

In this pattern, the workflow owns the entire conversation session. All messages are persisted in the workflow, and messages are injected into the workflow via hooks. The current and past turns are available in the UI by reconnecting to the main workflow stream. Alternatively to a stream, this could also use a step on the workflow side to persists (and possibly load) messages from an external store. Using an external database is more flexible, but less performant and harder to resume neatly than the built-in 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. Server-owned state, native message injection, and full session observability become increasingly valuable as your agent matures.

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. Each turn maps cleanly to an independent workflow run.

Multi-Party Injection

The multi-turn pattern also easily enables multi-party chat sessions. Parties can be system events, external services, and multiple users. Since a hook injects messages into workflow at any point, and the entire history is a single stream that clients can reconnect to, it doesn't matter where the injected messages come from. Here are different use-cases for multi-party chat sessions:

Internal system events like scheduled tasks, background jobs, or database triggers can inject updates into an active conversation.

app/api/internal/order-update/route.ts
import { chatMessageHook } from '@/ai/hooks/chat-message';

// Called by your order processing system when status changes
export async function POST(req: Request) {
  const { threadId, orderStatus } = await req.json();

  await chatMessageHook.resume(`thread:${threadId}`, { 
    message: `[System] Order status updated: ${orderStatus}`, 
  }); 

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

External webhooks from third-party services (Stripe, Twilio, etc.) can notify the conversation of events.

app/api/webhooks/stripe/route.ts
import { chatMessageHook } from '@/ai/hooks/chat-message';
import Stripe from 'stripe';

export async function POST(req: Request) {
  const event = await req.json() as Stripe.Event;

  if (event.type === 'payment_intent.succeeded') {
    const paymentIntent = event.data.object as Stripe.PaymentIntent;
    const threadId = paymentIntent.metadata.threadId; 

    await chatMessageHook.resume(`thread:${threadId}`, { 
      message: `[Stripe] Payment of $${(paymentIntent.amount / 100).toFixed(2)} received.`, 
    }); 
  }

  return Response.json({ received: true });
}

Multiple human users can participate in the same conversation. Each user's client connects to the same workflow stream.

app/api/chat/[id]/route.ts
import { chatMessageHook } from '@/ai/hooks/chat-message';
import { getUser } from '@/lib/auth';

export async function POST(req: Request, { params }) {
  const { id: threadId } = await params;
  const { message } = await req.json();
  const user = await getUser(req); 

  // Inject message with user attribution
  await chatMessageHook.resume(`thread:${threadId}`, { 
    message: `[${user.name}] ${message}`, 
  }); 

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