AI Agents

Chat Session Modeling

Chat sessions can be modeled at different layers of your architecture. The choice determines who owns message history, how long a workflow run stays active, and how clients reconnect after an interruption.

Workflow 5 applications should use AI SDK's WorkflowAgent for both patterns.

Single-turn workflows

Each user turn starts a new workflow run. The client or API owns conversation history and sends the complete UIMessage[] array with every request.

Workflow

Convert UI messages to model messages inside the workflow. WorkflowAgent writes durable ModelCallStreamPart values to the run stream.

workflows/chat.ts
import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow";
import { convertToModelMessages, type UIMessage } from "ai";
import { getWritable } from "workflow";
import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "./tools";

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

  const agent = new WorkflowAgent({
    model: "spacexai/grok-4.6",
    instructions: FLIGHT_ASSISTANT_PROMPT,
    tools: flightBookingTools,
  });

  return agent.stream({
    messages: await convertToModelMessages(messages),
    writable: getWritable<ModelCallStreamPart>(),
  });
}

API route

Convert the durable model-call stream to AI SDK UI chunks at the response boundary:

app/api/chat/route.ts
import { createModelCallToUIChunkTransform } from "@ai-sdk/workflow";
import { createUIMessageStreamResponse, type UIMessage } from "ai";
import { start } from "workflow/api";
import { chat } from "@/workflows/chat";

export async function POST(request: Request) {
  const { messages }: { messages: UIMessage[] } = await request.json();
  const run = await start(chat, [messages]);

  return createUIMessageStreamResponse({
    stream: run.readable.pipeThrough(createModelCallToUIChunkTransform()),
    headers: { "x-workflow-run-id": run.runId },
  });
}

Client

WorkflowChatTransport reconnects when the HTTP connection ends before the workflow stream finishes:

app/chat.tsx
"use client";

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

export function Chat({ initialMessages }) {
  const transport = useMemo(
    () => new WorkflowChatTransport({ api: "/api/chat" }),
    []
  );
  const { messages, sendMessage } = useChat({
    messages: initialMessages,
    transport,
  });

  // Render messages and call sendMessage({ text }) from your form.
}

Persist UIMessage[] in your application database. WorkflowAgent.stream() returns ModelMessage[], but there is no general conversion from model messages back to UI messages with all UI metadata intact.

Use the single-turn pattern when:

  • Your application already owns chat history
  • Each turn should run on the latest deployment
  • You want a simple request-to-run mapping
  • Approval responses or client-side tool results arrive as another message turn

Multi-turn workflows

A single workflow run can own the model-message history for the whole session. It waits on a Hook between turns, and external callers resume the Hook with the next message. The workflow run ID becomes the session identifier.

workflows/chat-session.ts
import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow";
import { type ModelMessage } from "ai";
import { defineHook, getWorkflowMetadata, getWritable } from "workflow";
import { z } from "zod";
import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "./tools";

export const chatMessageHook = defineHook({
  schema: z.object({ message: z.string() }),
});

export async function chatSession(initialMessages: ModelMessage[]) {
  "use workflow";

  const { workflowRunId } = getWorkflowMetadata();
  const hook = chatMessageHook.create({ token: workflowRunId });
  const writable = getWritable<ModelCallStreamPart>();
  let messages = [...initialMessages];

  const agent = new WorkflowAgent({
    model: "spacexai/grok-4.6",
    instructions: FLIGHT_ASSISTANT_PROMPT,
    tools: flightBookingTools,
  });

  const maxTurns = 100;
  for (let turn = 0; turn < maxTurns; turn++) {
    const result = await agent.stream({
      messages,
      writable,
      preventClose: true,
      sendFinish: false,
    });
    messages = result.messages;

    // Do not accept a follow-up that this run has no remaining turn to process.
    if (turn === maxTurns - 1) break;

    const { message } = await hook;
    if (message === "/done") break;
    messages = [...messages, { role: "user", content: message }];
  }

  return { messages };
}

Create the Hook once, outside the loop. Recreating the same token on every turn causes a Hook conflict. Intermediate turns use preventClose: true with sendFinish: false; the workflow closes the durable stream and emits the final UI finish when the run returns.

Start and resume the session

Use one route to create the run and a second route to deliver follow-up messages:

app/api/chat/route.ts
import { convertToModelMessages, type UIMessage } from "ai";
import { start } from "workflow/api";
import { chatSession } from "@/workflows/chat-session";

export async function POST(request: Request) {
  const { messages }: { messages: UIMessage[] } = await request.json();
  const run = await start(chatSession, [await convertToModelMessages(messages)]);
  return Response.json({ runId: run.runId });
}
app/api/chat/[runId]/message/route.ts
import { chatMessageHook } from "@/workflows/chat-session";

export async function POST(
  request: Request,
  { params }: { params: Promise<{ runId: string }> }
) {
  const { runId } = await params;
  const { message }: { message: string } = await request.json();
  await chatMessageHook.resume(runId, { message });
  return Response.json({ success: true });
}

Reconnect to the stream

WorkflowChatTransport counts transformed UI chunks, while the durable stream stores raw ModelCallStreamPart values. Always replay the raw stream from index 0 and apply the UI cursor in createModelCallToUIChunkTransform():

app/api/chat/[runId]/stream/route.ts
import { createModelCallToUIChunkTransform } from "@ai-sdk/workflow";
import { createUIMessageStreamResponse } from "ai";
import { getRun } from "workflow/api";

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

  if (!Number.isSafeInteger(startIndex) || startIndex < 0) {
    return Response.json(
      { error: "startIndex must be a non-negative safe integer" },
      { status: 400 }
    );
  }

  const run = getRun(runId);
  const stream = run
    .getReadable({ startIndex: 0 })
    .pipeThrough(createModelCallToUIChunkTransform({ uiStartIndex: startIndex }));

  return createUIMessageStreamResponse({
    stream,
    headers: { "x-workflow-run-id": runId },
  });
}

Persist UIMessage[] separately if the client must reconstruct user messages and UI metadata after a refresh. The workflow-owned ModelMessage[] history is the model's durable context, not a replacement for an application chat table.

Persist and display the full session

A multi-turn chat has three related records with different responsibilities:

  1. Workflow model history: ModelMessage[] is the durable context sent back to the model on each turn.
  2. Workflow run stream: ModelCallStreamPart values contain durable model and tool output for live delivery and reconnection.
  3. Application chat history: UIMessage[] preserves user messages, display metadata, attachments, and application-specific parts.

When the user sends the first message, start the run, store its run ID with the application chat record, and connect to the run stream. For each follow-up, optimistically add and persist the user UIMessage, then resume chatMessageHook with the corresponding text. After a refresh, load the persisted UI messages and reconnect through the stream route above using the last persisted UI chunk cursor. If you persist only user messages, replay model output from the beginning and merge by stable message IDs.

Do not treat the run stream as the only chat database. WorkflowAgent deliberately stores raw model-call parts, and user messages resumed through a Hook are not automatically written to that output stream. Keeping the application history separate avoids synthetic stream markers and preserves UI information that cannot be reconstructed from ModelMessage[].

The reconnect route replays raw ModelCallStreamPart values from index 0 because raw parts and transformed UI chunks do not have matching indexes. uiStartIndex prevents already-delivered UI chunks from being sent to the client again, but the server still transforms the earlier raw history. For very long streams, split conversations into bounded runs until WorkflowAgent exposes a persisted raw-to-UI cursor mapping.

Use the multi-turn pattern when:

  • One workflow should own the session's model context
  • Backend events or other users need to inject messages through Hooks
  • Full-session tracing is more important than running every turn on the newest deployment
  • The application is prepared to manage a long-lived run and stream cursor

Choosing a pattern

ConsiderationSingle-turnMulti-turn
State ownershipClient or application databaseWorkflow for model context; application database for UI history
Deployment versionLatest deployment per turnDeployment that started the run
Message injectionStart another runResume a Hook
Workflow complexityLowerHigher
Workflow time horizonOne model turnHours or longer
Observability scopePer turnFull session

Multi-turn works well for new durable sessions. The workflow owns model context, accepts messages from users and backend systems through the same Hook, and provides one full-session trace.

Single-turn works well when adapting an existing architecture. If the application already manages message state and you want to adopt durable agents incrementally, one workflow run per turn requires fewer lifecycle changes and always uses the latest deployment.

Multiplayer chat sessions

The multi-turn pattern also supports messages from system events, external services, and multiple users. Every source resumes the same Hook; the workflow queues those messages and processes them between model turns.

Scheduled tasks, background jobs, or database triggers can inject updates into an active conversation:

app/api/internal/flight-update/route.ts
import { chatMessageHook } from "@/workflows/chat-session";

export async function POST(request: Request) {
  const { runId, flightNumber, newStatus } = await request.json();

  await chatMessageHook.resume(runId, {
    message: `[System] Flight ${flightNumber} status updated: ${newStatus}`,
  });

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

A third-party webhook can notify the conversation about an external event:

app/api/webhooks/payment/route.ts
import { chatMessageHook } from "@/workflows/chat-session";

export async function POST(request: Request) {
  const { runId, paymentStatus, amount } = await request.json();

  if (paymentStatus === "succeeded") {
    await chatMessageHook.resume(runId, {
      message: `[Payment] Payment of $${amount.toFixed(2)} received. Your booking is confirmed.`,
    });
  }

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

Multiple authenticated users can participate in the same workflow-owned session. Include attribution when resuming the Hook:

app/api/chat/[runId]/message/route.ts
import { chatMessageHook } from "@/workflows/chat-session";
import { getUser } from "@/lib/auth";

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

  await chatMessageHook.resume(runId, {
    message: `[${user.name}] ${message}`,
  });

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

To preserve structured attribution across refreshes, persist the corresponding UIMessage using the application-history approach described above.