AI Agents

Resumable Streams

WorkflowChatTransport now ships in AI SDK as a 1:1 port, so import it from @ai-sdk/workflow (the @workflow/ai export is deprecated). See Resumable Streaming with WorkflowChatTransport for the full reference.

Network interruptions, page refreshes, or Vercel Functions timeouts can break a chat interface's connection to an in-progress agent.

Workflow runs and their attached streams are durable, so users can resume a stream without resending a message or waiting for the entire response again. The client can optionally sync only the data missed since the last connection.

Workflow SDK supports resumable streams, but the client must identify the stream and the position from which to reconnect. The WorkflowChatTransport helper is a drop-in AI SDK transport that handles this client-side resumption logic.

When deploying a streaming route to Vercel, enable request cancellation so a browser disconnect terminates that route's abandoned stream reader instead of letting the function run until FUNCTION_INVOCATION_TIMEOUT. See Avoiding Function Timeouts After Client Disconnects.

Implementing stream resumption

Add stream resumption to the Flight Booking Agent from the Building Durable AI Agents guide.

Return the run ID from your API

Modify your chat endpoint to include the workflow run ID in a response header. The run ID uniquely identifies the stream so the client knows which stream to reconnect to.

app/api/chat/route.ts
// ... imports ...

export async function POST(req: Request) {

  // ... existing logic to create the workflow ...

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

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

Add a stream reconnection endpoint

Create an API route that returns the stream for an existing run. The current endpoint always creates a new run.

app/api/chat/[id]/stream/route.ts
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);

  // Client provides the last chunk index they received
  const startIndexParam = searchParams.get("startIndex");
  const startIndex = startIndexParam
    ? parseInt(startIndexParam, 10)
    : undefined;

  // Instead of starting a new run, we fetch an existing run.
  const run = getRun(id);
  const readable = run.getReadable({ startIndex });

  // Provide the stream's tail index so the transport can resolve
  // negative startIndex values into absolute positions for retries.
  const tailIndex = await readable.getTailIndex();

  return createUIMessageStreamResponse({
    stream: readable,
    headers: {
      "x-workflow-stream-tail-index": String(tailIndex),
    },
  });
}

The startIndex parameter lets the client choose where to resume the stream. For example, if the function times out during streaming, the chat transport uses startIndex to resume from the last token it received. Negative values are also supported. A value of -5 starts 5 chunks before the end, which is useful for custom stream consumers that show recent output without replaying the full stream.

When using a negative startIndex, your stream endpoint must return an x-workflow-stream-tail-index header for relative resumption. If the header is missing, the transport replays the entire stream.

Use WorkflowChatTransport in the client

Replace the default transport in AI SDK's useChat with WorkflowChatTransport, and update the callbacks to store and use the latest run ID. For now, we'll store the run ID in localStorage. For your own app, this would be stored wherever you store session information.

app/page.tsx
"use client";

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

export default function ChatPage() {

  // Check for an active workflow run on mount
  const activeRunId = useMemo(() => {
    if (typeof window === "undefined") return;
    return localStorage.getItem("active-workflow-run-id") ?? undefined;
  }, []);

  const { messages, sendMessage, status } = useChat({
    resume: Boolean(activeRunId),
    transport: new WorkflowChatTransport({
      api: "/api/chat",

      // Store the run ID when a new chat starts
      onChatSendMessage: (response) => {
        const workflowRunId = response.headers.get("x-workflow-run-id");
        if (workflowRunId) {
          localStorage.setItem("active-workflow-run-id", workflowRunId);
        }
      },

      // Clear the run ID when the chat completes
      onChatEnd: () => {
        localStorage.removeItem("active-workflow-run-id");
      },

      // Use the stored run ID for reconnection
      prepareReconnectToStreamRequest: ({ api, ...rest }) => {
        const runId = localStorage.getItem("active-workflow-run-id");
        if (!runId) throw new Error("No active workflow run ID found");
        return {
          ...rest,
          api: `/api/chat/${encodeURIComponent(runId)}/stream`,
        };
      },
    }),
  });

  // ... render your chat UI
}

Open the flight booking example in another tab or refresh the page repeatedly. The client reconnects to the same chat stream each time.

How it works

  1. When the user sends a message, WorkflowChatTransport makes a POST request to /api/chat.
  2. The API starts a workflow and returns the run ID in the x-workflow-run-id header.
  3. onChatSendMessage stores this run ID in localStorage.
  4. If the stream is interrupted before receiving a finish chunk, the transport automatically reconnects.
  5. prepareReconnectToStreamRequest builds the reconnection URL using the stored run ID and points to /api/chat/{runId}/stream.
  6. The reconnection endpoint returns the stream from where the client left off.
  7. When the stream completes, onChatEnd clears the stored run ID.

This approach also handles page refreshes, as the client will automatically reconnect to the stream from the last known position when the user interface (UI) loads with a stored run ID, following the behavior of AI SDK's stream resumption.

Resuming from the end of the stream

By default, reconnecting replays the entire stream from the beginning (startIndex: 0). If you only need to show recent output (for example, when resuming a long conversation after a page refresh), you can set initialStartIndex to a negative value to read from the end of the stream instead:

const { messages, sendMessage } = useChat({
  resume: !!activeWorkflowRunId,
  transport: new WorkflowChatTransport({
    initialStartIndex: -20, // Only fetch the last 20 chunks
    // ... callbacks as above
  }),
});

This avoids replaying potentially thousands of chunks and lets the UI render faster. The negative value is resolved server-side, so -20 on a 500-chunk stream starts at chunk 480.

When using a negative initialStartIndex, the reconnection endpoint must return the x-workflow-stream-tail-index header (as shown in Step 2 above). The transport uses this header to compute absolute chunk positions so that retries after a disconnect resume from the correct position. If the header is missing, the transport falls back to startIndex: 0 (replaying the entire stream) and logs a warning.

Mid-part resumes

A workflow stream is a flat sequence of chunks, but the AI SDK's UI protocol groups chunks into logical parts (text-*, reasoning-*, tool-input-*) that must be opened with a *-start before any *-delta or *-end. A non-zero startIndex can land in the middle of an open part. See WorkflowChatTransport → Mid-part resumes for how this is handled and an example of rewinding to a step boundary on the server.