---
title: Chat Session Modeling
description: Model chat sessions at different architectural layers to control state ownership and handle interruptions.
type: guide
summary: Choose between single-turn and multi-turn workflow patterns for managing chat session state.
prerequisites:
  - /docs/ai
  - /docs/foundations/workflows-and-steps
related:
  - /docs/ai/message-queueing
  - /docs/ai/resumable-streams
  - /docs/foundations/hooks
  - /docs/api-reference/workflow-ai/durable-agent
  - /docs/api-reference/workflow/define-hook
---

# Chat Session Modeling



<Callout type="warn">
  The examples below use the deprecated `DurableAgent` API. For new agents, use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) and follow the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). The session-modeling patterns here (single- vs multi-turn, hooks, stream reconnection) apply to either API.
</Callout>

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
    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: "bedrock/claude-haiku-4-5-20251001-v1",
        instructions: FLIGHT_ASSISTANT_PROMPT,
        tools: flightBookingTools,
      });

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

  <Tab value="API Route">
    ```typescript title="app/api/chat/route.ts" lineNumbers
    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]); // [!code highlight]

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

  <Tab value="Client">
    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.

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

    import { useChat } from "@ai-sdk/react";
    import { WorkflowChatTransport } from "@ai-sdk/workflow"; // [!code highlight]
    import { useParams } from "next/navigation";
    import { useMemo } from "react";

    // Fetch existing messages from your backend
    async function getMessages(sessionId: string) { // [!code highlight]
      const res = await fetch(`/api/chats/${sessionId}/messages`); // [!code highlight]
      return res.json(); // [!code highlight]
    } // [!code highlight]

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

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

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

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

This is the pattern used in the [Building Durable AI Agents](/docs/ai) guide.

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 the turn 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 (either `messages: ModelMessage[]` or `uiMessages: UIMessage[]`)
* A hook on `useChat`in the client that calls an API to persist state (or localStorage, etc.), either on every new message, or `onFinish`
* The resumable stream attached to the workflow (see [Resumable Streams](/docs/ai/resumable-streams))
  * Note that user messages are not persisted to the stream by default, and need to be explicitly persisted separately

## 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.

For a full example of an agent using multi-turn workflows, check out the Flight Booking App example in the [Workflow Examples](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) repository.

A key challenge in multi-turn workflows is ensuring user messages appear in the correct order when replaying the stream (e.g., after a page refresh). Since the stream primarily contains AI responses, user messages must be explicitly marked in the stream so the client can reconstruct the full conversation.

<Tabs items={['Workflow', 'API Routes', 'Hook Definition', 'Client Hook']}>
  <Tab value="Workflow">
    ```typescript title="workflows/chat/index.ts" lineNumbers
    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"; // [!code highlight]

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

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

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

      const agent = new DurableAgent({
        model: "bedrock/claude-haiku-4-5-20251001-v1",
        instructions: 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, // [!code highlight] 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 // [!code highlight]
        const followUpId = `user-${runId}-${turnNumber}`; // [!code highlight]
        await writeUserMessageMarker(writable, followUp, followUpId); // [!code highlight]
        messages.push({ role: "user", content: followUp });
      }

      await writeStreamClose(writable); // [!code highlight]
      return { messages };
    }
    ```

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

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

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

    export async function writeStreamClose(writable: WritableStream<UIMessageChunk>) {
      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
    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]]); // [!code highlight]

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

    ```typescript title="app/api/chat/[id]/route.ts" lineNumbers
    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 // [!code highlight]
      await chatMessageHook.resume(runId, { message }); // [!code highlight]

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

    ```typescript title="app/api/chat/[id]/stream/route.ts" lineNumbers
    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); // [!code highlight]
      const stream = run.getReadable({ // [!code highlight]
        startIndex: startIndex ? parseInt(startIndex, 10) : undefined, // [!code highlight]
      }); // [!code highlight]

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

  <Tab value="Hook Definition">
    ```typescript title="workflows/chat/hooks/chat-message.ts" lineNumbers
    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. It handles:

    * Routing between the initial message endpoint and follow-up endpoint
    * Reconstructing user messages from stream markers for correct ordering on replay

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

    import type { UIMessage, UIDataTypes, ChatStatus } from "ai";
    import { useChat } from "@ai-sdk/react";
    import { WorkflowChatTransport } from "@ai-sdk/workflow";
    import { useState, useCallback, useMemo, useEffect, useRef } 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);
      const userMessagesRef = useRef<Map<string, UIMessage>>(new Map());

      // Check for existing session on mount // [!code highlight]
      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);
              userMessagesRef.current.clear();
            },
            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 // [!code highlight]
      const messages = useMemo(() => { // [!code highlight]
        const result: UIMessage[] = []; // [!code highlight]
        const seenContent = new Set<string>(); // [!code highlight]
        // [!code highlight]
        // Collect content from optimistic user messages // [!code highlight]
        for (const msg of rawMessages) { // [!code highlight]
          if (msg.role === "user") { // [!code highlight]
            const text = msg.parts.filter((p) => p.type === "text").map((p) => p.text).join(""); // [!code highlight]
            if (text) seenContent.add(text); // [!code highlight]
          } // [!code highlight]
        } // [!code highlight]
        // [!code highlight]
        for (const msg of rawMessages) { // [!code highlight]
          if (msg.role === "user") { // [!code highlight]
            result.push(msg); // [!code highlight]
            continue; // [!code highlight]
          } // [!code highlight]
          // [!code highlight]
          if (msg.role === "assistant") { // [!code highlight]
            // Process parts in order, splitting on user-message markers // [!code highlight]
            let currentParts: typeof msg.parts = []; // [!code highlight]
            let partIndex = 0; // [!code highlight]
            // [!code highlight]
            for (const part of msg.parts) { // [!code highlight]
              if (part.type === "data-workflow" && "data" in part) { // [!code highlight]
                const data = part.data as UserMessageData; // [!code highlight]
                if (data?.type === "user-message") { // [!code highlight]
                  // Flush accumulated assistant parts // [!code highlight]
                  if (currentParts.length > 0) { // [!code highlight]
                    result.push({ ...msg, id: `${msg.id}-${partIndex++}`, parts: currentParts }); // [!code highlight]
                    currentParts = []; // [!code highlight]
                  } // [!code highlight]
                  // Add user message if not duplicate // [!code highlight]
                  if (!seenContent.has(data.content)) { // [!code highlight]
                    seenContent.add(data.content); // [!code highlight]
                    result.push({ id: data.id, role: "user", parts: [{ type: "text", text: data.content }] }); // [!code highlight]
                  } // [!code highlight]
                  continue; // [!code highlight]
                } // [!code highlight]
              } // [!code highlight]
              currentParts.push(part); // [!code highlight]
            } // [!code highlight]
            // [!code highlight]
            if (currentParts.length > 0) { // [!code highlight]
              result.push({ ...msg, id: partIndex > 0 ? `${msg.id}-${partIndex}` : msg.id, parts: currentParts }); // [!code highlight]
            } // [!code highlight]
          } // [!code highlight]
        } // [!code highlight]
        return result; // [!code highlight]
      }, [rawMessages]); // [!code highlight]

      // Route messages to appropriate endpoint
      const sendMessage = useCallback(
        async (text: string) => {
          if (runId) {
            // Follow-up: send via hook resumption // [!code highlight]
            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);
        userMessagesRef.current.clear();
        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 (e.g., after a page refresh).

The client hook processes these markers by:

1. Iterating through message parts in order
2. When a `user-message` marker is found, flushing any accumulated assistant content and inserting the user message
3. Deduplicating against optimistic sends from the initial message

This ensures the conversation displays as User → AI → User → AI regardless of whether viewing live or replaying from 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. It also enables native message injection and full session observability, which becomes 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.

## Multiplayer Chat Sessions

The multi-turn pattern also easily enables multi-player chat sessions. New messages can come from system events, external services, and other 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-player chat sessions:

<Tabs items={['System Event', 'External Service', 'Multiple Users']}>
  <Tab value="System Event">
    Internal system events like scheduled tasks, background jobs, or database triggers can inject updates into an active conversation.

    ```typescript title="app/api/internal/flight-update/route.ts" lineNumbers
    import { chatMessageHook } from "@/workflows/chat/hooks/chat-message";

    // Called by your flight status monitoring system
    export async function POST(req: Request) {
      const { runId, flightNumber, newStatus } = await req.json();

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

      return Response.json({ success: true });
    }
    ```
  </Tab>

  <Tab value="External Service">
    External webhooks from third-party services (Stripe, Twilio, etc.) can notify the conversation of events.

    ```typescript title="app/api/webhooks/payment/route.ts" lineNumbers
    import { chatMessageHook } from "@/workflows/chat/hooks/chat-message";

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

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

      return Response.json({ received: true });
    }
    ```
  </Tab>

  <Tab value="Multiple Users">
    Multiple human users can participate in the same conversation. Each user's client connects to the same workflow stream.

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

    export async function POST(
      req: Request,
      { params }: { params: Promise<{ id: string }> }
    ) {
      const { id: runId } = await params;
      const { message } = await req.json();
      const user = await getUser(req); // [!code highlight]

      // Inject message with user attribution // [!code highlight]
      await chatMessageHook.resume(runId, { // [!code highlight]
        message: `[${user.name}] ${message}`, // [!code highlight]
      }); // [!code highlight]

      return Response.json({ success: true });
    }
    ```
  </Tab>
</Tabs>

## Related Documentation

* [Building Durable AI Agents](/docs/ai) - Foundation guide for durable agents
* [Message Queueing](/docs/ai/message-queueing) - Queueing messages during tool execution
* [`defineHook()` API Reference](/docs/api-reference/workflow/define-hook) - Hook configuration options
* [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) - AI SDK API for durable, resumable agents


---
title: Patterns for Defining Tools
description: Common patterns for defining tools in durable AI agents using Workflow SDK.
type: guide
summary: Define step-level and workflow-level tools for durable AI agents with stream writing and context access.
prerequisites:
  - /docs/ai
related:
  - /docs/ai/streaming-updates-from-tools
  - /docs/ai/sleep-and-delays
  - /docs/foundations/workflows-and-steps
  - /docs/api-reference/workflow/get-writable
---

# Patterns for Defining Tools



This page covers the details for some common patterns when defining tools for AI agents using Workflow SDK.

Using WorkflowAgent, we model most tools as steps. These can be anything from a simple function call to a entire multi-day long workflow.

## Accessing message context in tools

Just like in regular AI SDK tool definitions, tool in WorkflowAgent are called with a first argument of the tool's input parameters, and a second argument of the tool call context.

When you tool needs access to the full message history, you can access it via the `messages` property of the tool call context:

```typescript title="tools.ts" lineNumbers
import { Experimental_Agent as Agent } from "ai";
import type { ModelMessage } from "ai";

async function getWeather(
  { city }: { city: string },
  { messages, toolCallId }: { messages: ModelMessage[], toolCallId: string }) { // [!code highlight]
  "use step";
  return `Weather in ${city} is sunny`;
}
```

## Writing to Streams

As discussed in [Streaming Updates from Tools](/docs/ai/streaming-updates-from-tools), it's common to use a step just to call `getWritable()` for writing custom data parts to the stream.

This can be made generic, by creating a helper step function to write arbitrary data to the stream:

```typescript title="tools.ts" lineNumbers
import { getWritable } from "workflow";

async function writeToStream(data: any) {
  "use step";

  const writable = getWritable();
  const writer = writable.getWriter();
  await writer.write(data);
  writer.releaseLock();
}
```

## Step-Level vs Workflow-Level Tools

Tools can be implemented either at the step level or the workflow level, with different capabilities and constraints.

| Capability                            | Step-Level (`"use step"`) | Workflow-Level (`"use workflow"`) |
| ------------------------------------- | ------------------------- | --------------------------------- |
| `getWritable()`                       | ✅                         | ❌                                 |
| Automatic retries                     | ✅                         | ❌                                 |
| Side-effects (e.g. API calls) allowed | ✅                         | ❌                                 |
| `sleep()`                             | ❌                         | ✅                                 |
| `createWebhook()`                     | ❌                         | ✅                                 |

Tools can also combine both by starting out on the workflow level, and calling into steps for I/O operations, like so:

```typescript title="tools.ts" lineNumbers
import { sleep } from "workflow";
import type { LanguageModel, ModelMessage } from "ai";

// Step: handles I/O with retries
async function performFetch(url: string) {
  "use step";
  const response = await fetch(url);
  return response.json();
}

// Workflow-level: orchestrates steps and can use sleep()
async function executeFetchWithDelay({ url }: { url: string }) {
  const result = await performFetch(url);
  await sleep("5s"); // Only available at workflow level
  return result;
}
```


---
title: Human-in-the-Loop
description: Wait for human input or external events before proceeding in your AI agent workflows.
type: guide
summary: Pause agent workflows for human approval using hooks and webhooks, then resume on input.
prerequisites:
  - /docs/ai
  - /docs/foundations/hooks
related:
  - /docs/ai/chat-session-modeling
  - /docs/api-reference/workflow/create-webhook
  - /docs/api-reference/workflow/define-hook
  - /docs/foundations/workflows-and-steps
---

# Human-in-the-Loop



A common pre-requisite for running AI agents in production is the ability to wait for human input or external events before proceeding.

Workflow SDK's [webhook](/docs/api-reference/workflow/create-webhook) and [hook](/docs/api-reference/workflow/define-hook) primitives enable "human-in-the-loop" patterns where workflows pause until a human takes action, allowing smooth resumption of workflows even after days of inactivity, and provides stability across code deployments.

If you need to react to external events programmatically, see the [hooks](/docs/foundations/hooks) documentation for more information. This part of the guide will focus on the human-in-the-loop pattern, which is a subset of the more general hook pattern.

## How It Works

<Steps>
  <Step>
    `defineHook()` creates a typed hook that can be awaited in a workflow. When the tool is called, it creates a hook instance using the tool call ID as the token.
  </Step>

  <Step>
    The workflow pauses at `await hook` - no compute resources are consumed while waiting for the human to take action.
  </Step>

  <Step>
    The UI displays the pending tool call with its input data (flight details, price, etc.) and renders approval controls.
  </Step>

  <Step>
    The user submits their decision through an API endpoint, which resumes the hook with the approval data.
  </Step>

  <Step>
    The workflow receives the approval data and resumes execution.
  </Step>
</Steps>

While this demo will use a client side button for human approval, you could just as easily create a webhook and send the approval link over email or slack to resume the agent.

## Creating a Booking Approval Tool

Add a tool that allows the agent to deliberately pause execution until a human approves or rejects a flight booking:

<Steps>
  <Step>
    ### Define the Hook

    Create a typed hook with a Zod schema for validation:

    ```typescript title="workflow/hooks/booking-approval.ts" lineNumbers
    import { defineHook } from "workflow";
    import { z } from "zod";
    // ... existing imports ...

    export const bookingApprovalHook = defineHook({
      schema: z.object({
        approved: z.boolean(),
        comment: z.string().optional(),
      }),
    });

    // ... tool definitions ...
    ```
  </Step>

  <Step>
    ### Implement the Tool

    Create a tool that creates a hook instance using the tool call ID as the token. The UI will use this ID to submit the approval.

    {/*@skip-typecheck: incomplete code sample*/}

    ```typescript title="workflows/chat/steps/tools.ts" lineNumbers
    import { bookingApprovalHook } from "@/workflows/hooks/booking-approval"; // [!code highlight]

    // ...

    async function executeBookingApproval( // [!code highlight]
      { flightNumber, passengerName, price }: { flightNumber: string; passengerName: string; price: number }, // [!code highlight]
      { toolCallId }: { toolCallId: string } // [!code highlight]
    ) { // [!code highlight]
      // Note: No "use step" here - hooks are workflow-level primitives // [!code highlight]

      // Use the toolCallId as the hook token so the UI can reference it // [!code highlight]
      const hook = bookingApprovalHook.create({ token: toolCallId }); // [!code highlight]

      // Workflow pauses here until the hook is resolved // [!code highlight]
      const { approved, comment } = await hook; // [!code highlight]

      if (!approved) {
        return `Booking rejected: ${comment || "No reason provided"}`;
      }

      return `Booking approved for ${passengerName} on flight ${flightNumber}${comment ? ` - Note: ${comment}` : ""}`;
    }

    // ...

    // Adding the tool to the existing tool definitions
    export const flightBookingTools = {
      // ... existing tool definitions ...
      bookingApproval: {
        description: "Request human approval before booking a flight",
        inputSchema: z.object({
          flightNumber: z.string().describe("Flight number to book"),
          passengerName: z.string().describe("Name of the passenger"),
          price: z.number().describe("Total price of the booking"),
        }),
        execute: executeBookingApproval,
      },
    };
    ```

    <Callout type="info">
      Note that the `defineHook().create()` function must be called from within a workflow context, not from within a step. This is why `executeBookingApproval` does not have `"use step"` - it runs in the workflow context where hooks are available.
    </Callout>
  </Step>

  <Step>
    ### Create the API Route

    Create a new API endpoint that the UI will call to submit the approval decision:

    ```typescript title="app/api/hooks/approval/route.ts" lineNumbers
    import { bookingApprovalHook } from "@/workflows/hooks/booking-approval"; // [!code highlight]

    export async function POST(request: Request) {
      const { toolCallId, approved, comment } = await request.json();

      // Schema validation happens automatically // [!code highlight]
      // Can throw a zod schema validation error, or a
      await bookingApprovalHook.resume(toolCallId, { // [!code highlight]
        approved,
        comment,
      });

      return Response.json({ success: true });
    }
    ```
  </Step>

  <Step>
    ### Create the Approval Component

    Build a new component that reacts to the tool call data, and allows the user to approve or reject the booking:

    ```typescript title="components/booking-approval.tsx" lineNumbers
    "use client";

    import { useState } from "react";

    interface BookingApprovalProps {
      toolCallId: string;
      input?: {
        flightNumber: string;
        passengerName: string;
        price: number;
      };
      output?: string;
    }

    export function BookingApproval({ toolCallId, input, output }: BookingApprovalProps) {
      const [comment, setComment] = useState("");
      const [isSubmitting, setIsSubmitting] = useState(false);

      // If we have output, the approval has been processed
      if (output) {
        return (
          <div className="border rounded-lg p-4">
            <p className="text-sm text-muted-foreground">{output}</p>
          </div>
        );
      }

      const handleSubmit = async (approved: boolean) => {
        setIsSubmitting(true);
        try {
          await fetch("/api/hooks/approval", { // [!code highlight]
            method: "POST", // [!code highlight]
            headers: { "Content-Type": "application/json" }, // [!code highlight]
            body: JSON.stringify({ toolCallId, approved, comment }), // [!code highlight]
          }); // [!code highlight]
        } finally {
          setIsSubmitting(false);
        }
      };

      return (
        <div className="border rounded-lg p-4 space-y-4">
          <div className="space-y-2">
            <p className="font-medium">Approve this booking?</p>
            <div className="text-sm text-muted-foreground">
              {input && (
                <div className="space-y-2">
                  <div>Flight: {input.flightNumber}</div>
                  <div>Passenger: {input.passengerName}</div>
                  <div>Price: ${input.price}</div>
                </div>
              )}
            </div>
          </div>

          <textarea
            value={comment}
            onChange={(e) => setComment(e.target.value)}
            placeholder="Add a comment (optional)..."
            className="w-full border rounded p-2 text-sm"
            rows={2}
          />

          <div className="flex gap-2">
            <button
              type="button"
              onClick={() => handleSubmit(true)}
              disabled={isSubmitting}
              className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
            >
              {isSubmitting ? "Submitting..." : "Approve"}
            </button>
            <button
              type="button"
              onClick={() => handleSubmit(false)}
              disabled={isSubmitting}
              className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
            >
              {isSubmitting ? "Submitting..." : "Reject"}
            </button>
          </div>
        </div>
      );
    }
    ```
  </Step>

  <Step>
    ### Show the Tool Status in the UI

    Use the component we just created to render the tool call and approval controls in your chat interface:

    {/*@skip-typecheck: incomplete code sample*/}

    ```typescript title="app/page.tsx" lineNumbers
    // ... existing imports ...
    import { BookingApproval } from "@/components/booking-approval";

    export default function ChatPage() {

      // ...

      const { stop, messages, sendMessage, status, setMessages } =
        useChat<MyUIMessage>({
          // ... options
        });

      // ...

      return (
        <div className="flex flex-col w-full max-w-2xl pt-12 pb-24 mx-auto stretch">
          // ...

          <Conversation className="mb-10">
            <ConversationContent>
              {messages.map((message, index) => {
                const hasText = message.parts.some((part) => part.type === "text");

                return (
                  <div key={message.id}>
                    // ...
                    <Message from={message.role}>
                      <MessageContent>
                        {message.parts.map((part, partIndex) => {

                          // ...

                          if (
                            part.type === "tool-searchFlights" ||
                            part.type === "tool-checkFlightStatus" ||
                            part.type === "tool-getAirportInfo" ||
                            part.type === "tool-bookFlight" ||
                            part.type === "tool-checkBaggageAllowance"
                          ) {
                            // ... render other tools
                          }
                          if (part.type === "tool-bookingApproval") { // [!code highlight]
                            return ( // [!code highlight]
                              <BookingApproval // [!code highlight]
                                key={partIndex} // [!code highlight]
                                toolCallId={part.toolCallId} // [!code highlight]
                                input={part.input as any} // [!code highlight]
                                output={part.output as any} // [!code highlight]
                              /> // [!code highlight]
                            ); // [!code highlight]
                          } // [!code highlight]
                          return null;
                        })}
                      </MessageContent>
                    </Message>
                  </div>
                );
              })}
            </ConversationContent>
            <ConversationScrollButton />
          </Conversation>

          // ...
        </div>
      );
    }
    ```
  </Step>
</Steps>

## Using Webhooks Directly

For simpler cases where you don't need type-safe validation or programmatic resumption, you can use [`createWebhook()`](/docs/api-reference/workflow/create-webhook) directly. This generates a unique URL that can be called to resume the workflow:

```typescript title="workflows/chat/steps/tools.ts" lineNumbers
import { createWebhook } from "workflow";
import { z } from "zod";

async function executeBookingApproval(
  { flightNumber, passengerName, price }: { flightNumber: string; passengerName: string; price: number },
  { toolCallId }: { toolCallId: string }
) {
  const webhook = createWebhook(); // [!code highlight]

  // The webhook URL could be logged, sent via email, or stored for later use
  console.log("Approval URL:", webhook.url);

  // Workflow pauses here until the webhook is called // [!code highlight]
  const request = await webhook; // [!code highlight]
  const { approved, comment } = await request.json(); // [!code highlight]

  if (!approved) {
    return `Booking rejected: ${comment || "No reason provided"}`;
  }

  return `Booking approved for ${passengerName} on flight ${flightNumber}`;
}
```

The webhook URL can be called directly with a POST request containing the approval data. This is useful for:

* External systems that need to call back into your workflow
* Payment provider callbacks
* Email-based approval links

## Related Documentation

* [Hooks & Webhooks](/docs/foundations/hooks) - Complete guide to hooks and webhooks
* [`createWebhook()` API Reference](/docs/api-reference/workflow/create-webhook) - Webhook configuration options
* [`defineHook()` API Reference](/docs/api-reference/workflow/define-hook) - Type-safe hook definitions


---
title: Building Durable AI Agents
description: Build AI agents that survive crashes, scale across requests, and maintain state with durable LLM tool-call loops.
type: overview
summary: Convert a basic AI chat app into a durable, resumable agent using Workflow SDK.
related:
  - /docs/foundations/workflows-and-steps
  - /docs/foundations/streaming
  - /docs/foundations/errors-and-retries
  - /docs/ai/defining-tools
  - /docs/ai/resumable-streams
  - /docs/ai/human-in-the-loop
  - /docs/getting-started/next
---

# Building Durable AI Agents



AI agents are built on the primitive of LLM and tool-call loops, often with additional processes for data fetching, resource provisioning, or reacting to external events.

Workflow SDK makes your agents production-ready, by turning them into durable, resumable workflows. It transforms your LLM calls, tool executions, and other async operations into retryable, scalable, and observable steps.

<AgentTraces />

This guide walks you through converting a basic AI chat app into a durable AI agent using Workflow SDK.

## Why Durable Agents?

Aside from the usual challenges of getting your long-running tasks to be production-ready, building mature AI agents typically requires solving several **additional challenges**:

* **Statefulness**: Persisting chat sessions and turning LLM and tool calls into async jobs with workers and queues.
* **Observability**: Using services to collect traces and metrics, and managing them separately from your messages and user history.
* **Resumability**: Resuming streams requires not just storing your messages, but also storing streams, and piping them across services.
* **Human-in-the-loop**: Your client, API, and async job orchestration need to work together to create, track, route to, and display human approval requests, or similar webhook operations.

Workflow SDK provides all of these capabilities out of the box. Your agent becomes a workflow, your tools become steps, and the framework handles interplay with your existing infrastructure.

## Getting Started

To make an Agent durable, we first need an Agent, which we'll be setting up here. If you already have an app you'd like to follow along with, you can skip this section.

For our example, we'll need an app with a simple chat interface and an API route calling an LLM, so that we can add Workflow SDK to it. We'll use the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example as a starting point, which comes with a chat interface built using Next.js, AI SDK, and Shadcn UI.

<Steps>
  <Step>
    ### Clone example app

    We'll need an app with a simple chat interface and an API route calling an LLM, so that we can add Workflow SDK to it. For the follow-along steps, we'll use the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example as a starting point, which comes with a chat interface built using Next.js, AI SDK, and Shadcn UI.

    If you have your own project, you can skip this step, and simply apply the changes of the following steps to your own project.

    ```bash
    git clone https://github.com/vercel/workflow-examples -b plain-ai-sdk
    cd workflow-examples/flight-booking-app
    ```
  </Step>

  <Step>
    ### Set up API keys

    In order to connect to an LLM, we'll need to set up an API key. The easiest way to do this is to use Vercel Gateway (works with all providers at zero markup), or you can configure a custom provider.

    <Tabs items={['Gateway', 'Custom Provider']}>
      <Tab value="Gateway">
        Get a Gateway API key from the [Vercel Gateway](https://vercel.com/docs/gateway/api-reference/overview) page.

        Then add it to your `.env.local` file:

        ```bash title=".env.local" lineNumbers
        GATEWAY_API_KEY=...
        ```
      </Tab>

      <Tab value="Custom Provider">
        This is an example of how to use the OpenAI provider for AI SDK. For details on other providers and more details, see the [AI SDK provider guide](https://ai-sdk.dev/providers/ai-sdk-providers).

        <CodeBlockTabs defaultValue="npm">
          <CodeBlockTabsList>
            <CodeBlockTabsTrigger value="npm">
              npm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="pnpm">
              pnpm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="yarn">
              yarn
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="bun">
              bun
            </CodeBlockTabsTrigger>
          </CodeBlockTabsList>

          <CodeBlockTab value="npm">
            ```bash
            npm i @ai-sdk/openai
            ```
          </CodeBlockTab>

          <CodeBlockTab value="pnpm">
            ```bash
            pnpm add @ai-sdk/openai
            ```
          </CodeBlockTab>

          <CodeBlockTab value="yarn">
            ```bash
            yarn add @ai-sdk/openai
            ```
          </CodeBlockTab>

          <CodeBlockTab value="bun">
            ```bash
            bun add @ai-sdk/openai
            ```
          </CodeBlockTab>
        </CodeBlockTabs>

        Set your OpenAI API key in your environment variables:

        ```bash title=".env.local" lineNumbers
        OPENAI_API_KEY=...
        ```

        Then modify your API endpoint to use the OpenAI provider:

        {/* @skip-typecheck: incomplete code sample */}

        ```typescript title="app/api/chat/route.ts" lineNumbers
        // ...
        import { openai } from "@ai-sdk/openai"; // [!code highlight]

        export async function POST(req: Request) {
          // ...
          const agent = new Agent({
            // This uses the OPENAI_API_KEY environment variable by default, but you
            // can also pass { apiKey: string } as an option.
            model: openai("gpt-5.1"), // [!code highlight]
            // ...
          });
        ```
      </Tab>
    </Tabs>
  </Step>

  <Step>
    ### Get familiar with the code

    Let's take a moment to see what we're working with. Run the app with `npm run dev` and open [http://localhost:3000](http://localhost:3000) in your browser. You should see a simple chat interface to play with. Go ahead and give it a try.

    The core code that makes all of this happen is quite simple. Here's a breakdown of the main parts. Note that there's no changes needed here, we're simply taking a look at the code to understand what's happening.

    <Tabs items={['API Route', 'Tools', 'Client']}>
      <Tab value="API Route">
        Our API route makes a simple call to [AI SDK's `ToolLoopAgent` class](https://ai-sdk.dev/docs/agents/overview), which encapsulates the LLM call, tool execution loop, and stopping conditions on top of [AI SDK's `streamText` function](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text#streamtext). This is also where we pass tools to the agent.

        ```typescript title="app/api/chat/route.ts" lineNumbers
        import { ToolLoopAgent } from "ai";
        import type { UIMessage } from "ai";
        import { convertToModelMessages, createUIMessageStreamResponse } from "ai";

        export async function POST(req: Request) {
          const { messages }: { messages: UIMessage[] } = await req.json();
          const agent = new ToolLoopAgent({ // [!code highlight]
            model: "bedrock/claude-4-5-haiku-20251001-v1",
            instructions: FLIGHT_ASSISTANT_PROMPT,
            tools: flightBookingTools,
          });
          const modelMessages = await convertToModelMessages(messages);
          const stream = await agent.stream({ messages: modelMessages }); // [!code highlight]
          return createUIMessageStreamResponse({
            stream: stream.toUIMessageStream(),
          });
        }
        ```
      </Tab>

      <Tab value="Tools">
        Our tools are mostly mocked out for the sake of the example. We use AI SDK's `tool` function to define the tool, and pass it to the agent. In your own app, this might be any kind of tool call, like database queries, calls to external services, etc.

        ```typescript title="workflows/chat/steps/tools.ts" lineNumbers
        import { tool } from "ai";
        import { z } from "zod";

        export const tools = {
          searchFlights: tool({
            description: "Search for flights",
            inputSchema: z.object({ from: z.string(), to: z.string(), date: z.string() }),
            execute: searchFlights,
          }),
        };

        async function searchFlights({ from, to, date }: { from: string; to: string; date: string }) {
          // ... generate some fake flights
        }
        ```
      </Tab>

      <Tab value="Client">
        Our `ChatPage` component has a lot of logic for nicely displaying the chat messages, but at it's core, it's simply managing input/output for the [`useChat` hook](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat#usechat) from AI SDK.

        ```typescript title="app/chat.tsx" lineNumbers
        "use client";

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

        export default function ChatPage() {
          const { messages, input, handleInputChange, handleSubmit } = useChat({ // [!code highlight]
            // ... other options ...
          });

          // ... more UI logic

          return (
            <div>
              // This is a simplified example of the rendering logic
              {messages.map((m) => (
                <div key={m.id}>
                  <strong>{m.role}:</strong>
                  {m.parts.map((part, i) => {
                    if (part.type === "text") { // [!code highlight]
                      return <span key={i}>{part.text}</span>;
                    }
                    if (part.type === "tool-searchFlights") { // [!code highlight]
                      // ... some special rendering for our tool results
                    }
                    return null;
                  })}
                </div>
              ))}
              <form onSubmit={handleSubmit}>
                <input
                  value={input}
                  onChange={handleInputChange}
                  placeholder="Type a message..."
                />
              </form>
            </div>
          );
        }
        ```
      </Tab>
    </Tabs>
  </Step>
</Steps>

## Integrating Workflow SDK

Now that we have a basic agent using AI SDK, we can modify it to make it durable.

<Steps>
  <Step>
    ### Install Dependencies

    Add the Workflow SDK packages to your project:

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i workflow @ai-sdk/workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add workflow @ai-sdk/workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add workflow @ai-sdk/workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add workflow @ai-sdk/workflow
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    and extend the Next.js config to transform your workflow code (see [Getting Started](/docs/getting-started/next) for more details).

    ```typescript title="next.config.ts" lineNumbers
    import { withWorkflow } from "workflow/next";
    import type { NextConfig } from "next";

    const nextConfig: NextConfig = {
      // ... rest of your Next.js config
    };

    export default withWorkflow(nextConfig);
    ```
  </Step>

  <Step>
    ### Create a Workflow Function

    Move the agent logic into a separate function, which will serve as our workflow definition.

    {/* @skip-typecheck: Shows two mutually exclusive model options */}

    ```typescript title="workflows/chat/workflow.ts" lineNumbers
    import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow"; // [!code highlight]
    import { getWritable } from "workflow"; // [!code highlight]
    import { tools } from "@/ai/tools";
    import { openai } from "@ai-sdk/openai";
    import { convertToModelMessages, type UIMessage } from "ai";

    export async function chatWorkflow(messages: UIMessage[]) {
      "use workflow"; // [!code highlight]

      const writable = getWritable<ModelCallStreamPart>(); // [!code highlight]

      const agent = new WorkflowAgent({ // [!code highlight]

        // If using AI Gateway, just specify the model name as a string:
        model: "bedrock/claude-4-5-haiku-20251001-v1", // [!code highlight]

        // ELSE if using a custom provider, pass the provider call as an argument:
        model: openai("gpt-5.1"), // [!code highlight]

        instructions: FLIGHT_ASSISTANT_PROMPT,
        tools: flightBookingTools,
      });

      const modelMessages = await convertToModelMessages(messages); // [!code highlight]

      await agent.stream({ // [!code highlight]
        messages: modelMessages,
        writable,
      });
    }
    ```

    Key changes:

    * Add the `"use workflow"` directive to mark our Agent as a workflow function
    * Replace the in-memory agent with [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) from `@ai-sdk/workflow`. This runs the agent loop inside a workflow, persists state across step boundaries, and lets tool executions marked with `"use step"` retry automatically.
    * Convert AI SDK `UIMessage` values to model messages inside the workflow before calling `agent.stream()`.
    * Use [`getWritable()`](/docs/api-reference/workflow/get-writable) to get a stream for agent output. `WorkflowAgent` writes `ModelCallStreamPart` chunks to this persistent stream, and API endpoints can read from a run's stream at any time.
  </Step>

  <Step>
    ### Update the API Route

    Remove the agent call that we just extracted, and replace it with a call to `start()` to run the workflow:

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

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

      const run = await start(chatWorkflow, [messages]); // [!code highlight]

      return createUIMessageStreamResponse({
        stream: run.readable.pipeThrough(createModelCallToUIChunkTransform()), // [!code highlight]
      });
    }
    ```

    Key changes:

    * Call `start()` to run the workflow function. This returns a `Run` object, which contains the run ID and the readable stream (see [Starting Workflows](/docs/foundations/starting-workflows) for more details on the `Run` object).
    * Pass the `writable` to `agent.stream()` instead of returning a stream directly, ensuring all the Agent output is written to the run's stream.
    * Pipe the readable stream through `createModelCallToUIChunkTransform()` so the raw model-call chunks become AI SDK UI message chunks before they are returned to the client.
  </Step>

  <Step>
    ### Convert Tools to Steps

    Mark all tool definitions with `"use step"` to make them durable. This enables automatic retries and observability for each tool call:

    {/* @skip-typecheck: incomplete code sample */}

    ```typescript title="workflows/chat/steps/tools.ts​" lineNumbers
    // ...

    export async function searchFlights(
      // ... arguments
    ) {
      "use step"; // [!code highlight]

      // ... rest of the tool code
    }

    export async function checkFlightStatus(
      // ... arguments
    ) {
      "use step"; // [!code highlight]

      // ... rest of the tool code
    }

    export async function getAirportInfo(
      // ... arguments
    ) {
      "use step"; // [!code highlight]

      // ... rest of the tool code
    }

    export async function bookFlight({
      // ... arguments
    }) {
      "use step"; // [!code highlight]

      // ... rest of the tool code
    }

    export async function checkBaggageAllowance(
      // ... arguments
    ) {
        "use step"; // [!code highlight]

        // ... rest of the tool code
      }
    }
    ```

    With `"use step"`:

    * The tool execution runs in a separate step with full Node.js access. In production, each step is executed in a separate worker process, which scales automatically with your workload.
    * Failed tool calls are automatically retried (up to 3 times by default). See [Errors and Retries](/docs/foundations/errors-and-retries) for more details.
    * Each tool execution appears as a discrete step in observability tools. See [Observability](/docs/observability) for more details.
  </Step>
</Steps>

That's all you need to do to convert your basic AI SDK agent into a durable agent. If you run your development server, and send a chat message, you should see your agent respond just as before, but now with added durability and observability.

## Observability

In your app directory, you can open up the observability dashboard to see your workflow in action, using the CLI:

```bash
npx workflow web
```

This opens a local dashboard showing all workflow runs and their status, as well as a trace viewer to inspect the workflow in detail, including retry attempts, and the data being passed between steps.

## Next Steps

Now that you have a basic durable agent, it's a only a short step to add these additional features:

<Cards>
  <Card title="Streaming Updates from Tools" href="/docs/ai/streaming-updates-from-tools">
    Stream progress updates from tools to the UI while they're executing.
  </Card>

  <Card title="Resumable Streams" href="/docs/ai/resumable-streams">
    Enable clients to reconnect to interrupted streams without losing data.
  </Card>

  <Card title="Sleep, Suspense, and Scheduling" href="/docs/ai/sleep-and-delays">
    Add native sleep, suspense, and scheduling functionality to your Agent and workflow.
  </Card>

  <Card title="Human-in-the-Loop" href="/docs/ai/human-in-the-loop">
    Implement approval steps to wait for human input or external events.
  </Card>
</Cards>

## Complete Example

A complete example that includes all of the above, plus all of the "next steps" features is available on the main branch of the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example.

## Related Documentation

* [Tools](/docs/ai/defining-tools) - Patterns for defining tools for your agent
* [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) - AI SDK API for durable, resumable agents
* [Workflows and Steps](/docs/foundations/workflows-and-steps) - Core concepts
* [Streaming](/docs/foundations/streaming) - In-depth streaming guide
* [Errors and Retries](/docs/foundations/errors-and-retries) - Error handling patterns


---
title: Queueing User Messages
description: Inject messages during an agent's turn, before tool calls complete or while the model is reasoning.
type: guide
summary: Inject user messages mid-turn using the `prepareStep` callback to influence the agent's next step.
prerequisites:
  - /docs/ai
related:
  - /docs/ai/chat-session-modeling
  - /docs/api-reference/workflow-ai/durable-agent
  - /docs/api-reference/workflow/define-hook
---

# Queueing User Messages



When using [multi-turn workflows](/docs/ai/chat-session-modeling#multi-turn-workflows), messages typically arrive between agent turns. The workflow waits at a hook, receives a message, then starts a new turn. But sometimes you need to inject messages *during* an agent's turn, before tool calls complete or while the model is reasoning.

`WorkflowAgent`'s `prepareStep` callback enables this by running before each step in the agent loop, giving you a chance to inject queued messages into the conversation. `prepareStep` also allows you to modify the model choice and existing messages mid-turn, see AI SDK's [prepareStep callback](https://ai-sdk.dev/docs/agents/loop-control#prepare-step) for more details.

## When to Use This

Message queueing is useful when:

* Users send follow-up messages while the agent is still searching for flights or processing bookings
* External systems need to inject context mid-turn (e.g., a flight status webhook fires during processing)
* You want messages to influence the agent's next step rather than waiting for the current turn to complete

<Callout type="info">
  If you just need basic multi-turn conversations where messages arrive between turns, see [Chat Session Modeling](/docs/ai/chat-session-modeling). This guide covers the more advanced case of injecting messages *during* turns.
</Callout>

## The `prepareStep` Callback

The `prepareStep` callback runs before each step in the agent loop. It receives the current state and can modify the messages sent to the model:

```typescript lineNumbers
import type { ModelMessage, LanguageModel } from "ai";

interface PrepareStepInfo {
  model: string | (() => Promise<LanguageModel>);    // Current model
  stepNumber: number;                                // 0-indexed step count
  steps: StepResult[];                               // Previous step results
  messages: ModelMessage[];                          // Messages to be sent
}

interface PrepareStepResult {
  model?: string | (() => Promise<LanguageModel>);   // Override model
  messages?: ModelMessage[];                         // Override messages
}
```

## Injecting Queued Messages

Once you have a [multi-turn workflow](/docs/ai/chat-session-modeling#multi-turn-workflows), you can combine a message queue with `prepareStep` to inject messages that arrive during processing:

```typescript title="workflows/chat/index.ts" lineNumbers
import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow";
import { getWritable, getWorkflowMetadata } from "workflow";
import { chatMessageHook } from "./hooks/chat-message";
import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "./steps/tools";
import type { ModelMessage } from "ai";

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

  const { workflowRunId: runId } = getWorkflowMetadata();
  const writable = getWritable<ModelCallStreamPart>();
  const messageQueue: Array<{ role: "user"; content: string }> = []; // [!code highlight]

  const agent = new WorkflowAgent({
    model: "bedrock/claude-haiku-4-5-20251001-v1",
    instructions: FLIGHT_ASSISTANT_PROMPT,
    tools: flightBookingTools,
  });

  // Listen for messages in background (non-blocking) // [!code highlight]
  const hook = chatMessageHook.create({ token: runId }); // [!code highlight]
  hook.then(({ message }) => { // [!code highlight]
    messageQueue.push({ role: "user", content: message }); // [!code highlight]
  }); // [!code highlight]

  await agent.stream({
    messages: initialMessages,
    writable,
    prepareStep: ({ messages: currentMessages }) => { // [!code highlight]
      // Inject any queued messages before the next LLM call // [!code highlight]
      if (messageQueue.length > 0) { // [!code highlight]
        const newMessages = messageQueue.splice(0); // Drain queue // [!code highlight]
        return { // [!code highlight]
          messages: [ // [!code highlight]
            ...currentMessages, // [!code highlight]
            ...newMessages.map((m) => ({ // [!code highlight]
              role: m.role, // [!code highlight]
              content: [{ type: "text" as const, text: m.content }], // [!code highlight]
            })), // [!code highlight]
          ], // [!code highlight]
        }; // [!code highlight]
      } // [!code highlight]
      return {}; // [!code highlight]
    }, // [!code highlight]
  });
}
```

Messages sent via `chatMessageHook.resume()` accumulate in the queue and get injected before the next step, whether that's a tool call or another LLM request.

<Callout type="info">
  The `prepareStep` callback receives messages in `ModelMessage[]` format (with content arrays), which is the internal format used by the AI SDK.
</Callout>

## Combining with Multi-Turn Sessions

You can also combine message queueing with the standard multi-turn pattern:

```typescript title="workflows/chat/index.ts" lineNumbers
import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow";
import { getWritable, getWorkflowMetadata } from "workflow";
import { chatMessageHook } from "./hooks/chat-message";
import type { ModelMessage } from "ai";

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

  const { workflowRunId: runId } = getWorkflowMetadata();
  const writable = getWritable<ModelCallStreamPart>();
  const messages: ModelMessage[] = [...initialMessages];
  const messageQueue: Array<{ role: "user"; content: string }> = [];

  const agent = new WorkflowAgent({ /* ... */ });
  const hook = chatMessageHook.create({ token: runId });

  while (true) {
    // Set up non-blocking listener for mid-turn messages // [!code highlight]
    let pendingMessage: string | null = null; // [!code highlight]
    hook.then(({ message }) => { // [!code highlight]
      if (message === "/done") return; // [!code highlight]
      messageQueue.push({ role: "user", content: message }); // [!code highlight]
      pendingMessage = message; // [!code highlight]
    }); // [!code highlight]

    const result = await agent.stream({
      messages,
      writable,
      preventClose: true,
      prepareStep: ({ messages: currentMessages }) => {
        // Inject queued messages during turn // [!code highlight]
        if (messageQueue.length > 0) {
          const newMessages = messageQueue.splice(0);
          return {
            messages: [
              ...currentMessages,
              ...newMessages.map((m) => ({
                role: m.role,
                content: [{ type: "text" as const, text: m.content }],
              })),
            ],
          };
        }
        return {};
      },
    });

    messages.push(...result.messages.slice(messages.length));

    // Wait for next message (either queued during turn or new) // [!code highlight]
    const { message: followUp } = pendingMessage ? { message: pendingMessage } : await hook; // [!code highlight]
    if (followUp === "/done") break;

    messages.push({ role: "user", content: followUp });
  }
}
```

## Related Documentation

* [Chat Session Modeling](/docs/ai/chat-session-modeling) - Single-turn vs multi-turn patterns
* [Building Durable AI Agents](/docs/ai) - Complete guide to creating durable agents
* [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) - AI SDK API for durable, resumable agents
* [`defineHook()` API Reference](/docs/api-reference/workflow/define-hook) - Hook configuration options


---
title: Resumable Streams
description: Handle network interruptions, page refreshes, and timeouts without losing agent progress.
type: guide
summary: Reconnect to interrupted agent streams using `WorkflowChatTransport` without losing progress.
prerequisites:
  - /docs/ai
  - /docs/foundations/streaming
related:
  - /docs/ai/chat-session-modeling
  - /docs/api-reference/workflow-ai/workflow-chat-transport
  - /docs/api-reference/workflow-api/get-run
---

# Resumable Streams



<Callout type="warn">
  `WorkflowChatTransport` now ships in AI SDK as a 1:1 port — import it from `@ai-sdk/workflow` (the `@workflow/ai` export is deprecated). See [Resumable Streaming with `WorkflowChatTransport`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) for the full reference.
</Callout>

When building chat interfaces, it's common to run into network interruptions, page refreshes, or serverless function timeouts, which can break the connection to an in-progress agent.

Where a standard chat implementation would require the user to resend their message and wait for the entire response again, workflow runs are durable, and so are the streams attached to them. This means a stream can be resumed at any point, optionally only syncing the data that was missed since the last connection.

Resumable streams come out of the box with Workflow SDK, however, the client needs to recognize that a stream exists, and needs to know which stream to reconnect to, and needs to know where to start from. For this, Workflow SDK provides the [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport) helper, a drop-in transport for the AI SDK that handles client-side resumption logic for you.

## Implementing stream resumption

Let's add stream resumption to our Flight Booking Agent that we build in the [Building Durable AI Agents](/docs/ai) guide.

<Steps>
  <Step>
    ### 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 run's stream, so it allows the client to know which stream to reconnect to.

    {/*@skip-typecheck: incomplete code sample*/}

    ```typescript title="app/api/chat/route.ts" lineNumbers
    // ... 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: { // [!code highlight]
          "x-workflow-run-id": run.runId, // [!code highlight]
        }, // [!code highlight]
      });
    }
    ```
  </Step>

  <Step>
    ### Add a Stream Reconnection Endpoint

    Currently we only have one API endpoint that always creates a new run, so we need to create a new API route that returns the stream for an existing run:

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

    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"); // [!code highlight]
      const startIndex = startIndexParam
        ? parseInt(startIndexParam, 10)
        : undefined;

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

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

      return createUIMessageStreamResponse({
        stream: readable, // [!code highlight]
        headers: { // [!code highlight]
          "x-workflow-stream-tail-index": String(tailIndex), // [!code highlight]
        }, // [!code highlight]
      });
    }
    ```

    The `startIndex` parameter ensures the client can choose where to resume the stream from. For instance, if the function times out during streaming, the chat transport will use `startIndex` to resume the stream exactly from the last token it received. Negative values are also supported (e.g. `-5` starts 5 chunks before the end), which is useful for custom stream consumers (such as a dashboard showing recent output) that want to show the most recent output without replaying the full stream.

    When using a negative `startIndex`, your stream endpoint must return a `x-workflow-stream-tail-index` header in order for relative resumption to work. Missing the header will fall back to replaying the entire stream.
  </Step>

  <Step>
    ### Use `WorkflowChatTransport` in the Client

    Replace the default transport in AI-SDK's `useChat` with [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport), 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.

    ```typescript title="app/page.tsx" lineNumbers
    "use client";

    import { useChat } from "@ai-sdk/react";
    import { WorkflowChatTransport } from "@ai-sdk/workflow"; // [!code highlight]
    import { useMemo, useState } from "react";

    export default function ChatPage() {

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

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

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

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

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

      // ... render your chat UI
    }
    ```
  </Step>
</Steps>

Now try the flight booking example again. Open it up in a separate tab, or spam the refresh button, and see how the client connects to the same chat stream every time.

## How It Works

1. When the user sends a message, `WorkflowChatTransport` makes a POST 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, pointing to the new endpoint `/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 UI loads with a stored run ID, following the behavior of [AI SDK's stream resumption](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-resume-streams#chatbot-resume-streams).

### 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:

{/*@skip-typecheck: incomplete code sample*/}

```typescript
const { messages, sendMessage } = useChat({
  resume: !!activeWorkflowRunId,
  transport: new WorkflowChatTransport({
    initialStartIndex: -20, // Only fetch the last 20 chunks // [!code highlight]
    // ... 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.

<Callout>
  When using a negative `initialStartIndex`, the reconnection endpoint **must** return the `x-workflow-stream-tail-index` header (as shown in [Step 2](#add-a-stream-reconnection-endpoint) 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.
</Callout>

### 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](/docs/api-reference/workflow-ai/workflow-chat-transport#mid-part-resumes) for how this is handled and an example of rewinding to a step boundary on the server.

## Related Documentation

* [`WorkflowChatTransport` API Reference](/docs/api-reference/workflow-ai/workflow-chat-transport) - Full configuration options
* [Streaming](/docs/foundations/streaming) - Understanding workflow streams
* [`getRun()` API Reference](/docs/api-reference/workflow-api/get-run) - Retrieving existing runs


---
title: Sleep, Suspense, and Scheduling
description: Schedule recurring actions, handle rate limiting, and wait for external state in AI agents.
type: guide
summary: Pause agent execution with `sleep()` for scheduling, rate limiting, and waiting on external state.
prerequisites:
  - /docs/ai
related:
  - /docs/ai/defining-tools
  - /docs/ai/streaming-updates-from-tools
  - /docs/foundations/errors-and-retries
  - /docs/api-reference/workflow/sleep
---

# Sleep, Suspense, and Scheduling



AI agents sometimes need to pause execution in order to schedule recurring or future actions, wait before retrying an operation (e.g. for rate limiting), or wait for external state to be available.

Workflow SDK's `sleep` function enables Agents to pause execution without consuming resources, and resume at a specified time, after a specified duration, or in response to an external event. Workflow operation that suspend will survive restarts, new deploys, and infrastructure changes, independent of whether the suspense takes seconds or months.

<Callout type="info">
  See the [`sleep()` API Reference](/docs/api-reference/workflow/sleep) for the full list of supported duration formats and detailed API documentation, and see the [hooks](/docs/foundations/hooks) documentation for more information on how to resume in response to external events.
</Callout>

## Adding a Sleep Tool

Sleep is a built-in function in Workflow SDK, so exposing it as a tool is as simple as wrapping it in a tool definition. Learn more about how to define tools in [Patterns for Defining Tools](/docs/ai/defining-tools).

<Steps>
  <Step>
    ### Define the Tool

    Add a new "sleep" tool to the `tools` defined in `workflows/chat/steps/tools.ts`:

    ```typescript title="workflows/chat/steps/tools.ts" lineNumbers
    import { getWritable, sleep } from "workflow"; // [!code highlight]

    // ... existing imports ...

    async function executeSleep( // [!code highlight]
      { durationMs }: { durationMs: number }, // [!code highlight]
    ) { // [!code highlight]
      // Note: No "use step" here - sleep is a workflow-level function // [!code highlight]
      await sleep(durationMs); // [!code highlight]
      return { message: `Slept for ${durationMs}ms` }; // [!code highlight]
    }

    // ... existing tool functions ...

    export const flightBookingTools = {
     // ... existing tool definitions ...
     sleep: { // [!code highlight]
      description: "Pause execution for a specified duration", // [!code highlight]
      inputSchema: z.object({ // [!code highlight]
        durationMs: z.number().describe("Duration to sleep in milliseconds"), // [!code highlight]
      }), // [!code highlight]
      execute: executeSleep, // [!code highlight]
     } // [!code highlight]
    }
    ```

    <Callout type="info">
      Note that the `sleep()` function must be called from within a workflow context, not from within a step. This is why `executeSleep` does not have `"use step"` - it runs in the workflow context where `sleep()` is available.
    </Callout>

    This already makes the full sleep functionality available to the Agent!
  </Step>

  <Step>
    ### Show the tool status in the UI

    To round it off, extend the UI to display the tool call status. This can be done either by displaying the tool call information directly, or by emitting custom data parts to the stream (see [Streaming Updates from Tools](/docs/ai/streaming-updates-from-tools) for more details). In this case, since there aren't any fine-grained progress updates to show, we'll just display the tool call information directly:

    {/*@skip-typecheck: incomplete code sample*/}

    ```typescript title="app/page.tsx" lineNumbers
    export default function ChatPage() {

      // ...

      const { stop, messages, sendMessage, status, setMessages } =
        useChat<MyUIMessage>({
          // ... options
        });

      // ...

      return (
        <div className="flex flex-col w-full max-w-2xl pt-12 pb-24 mx-auto stretch">
          // ...

          <Conversation className="mb-10">
            <ConversationContent>
              {messages.map((message, index) => {
                const hasText = message.parts.some((part) => part.type === "text");

                return (
                  <div key={message.id}>
                    // ...
                    <Message from={message.role}>
                      <MessageContent>
                        {message.parts.map((part, partIndex) => {

                          // ...

                          if (
                            part.type === "tool-searchFlights" ||
                            part.type === "tool-checkFlightStatus" ||
                            part.type === "tool-getAirportInfo" ||
                            part.type === "tool-bookFlight" ||
                            part.type === "tool-checkBaggageAllowance"
                            part.type === "tool-sleep" // [!code highlight]
                          ) {
                            // ...
                          }
                          return null;
                        })}
                      </MessageContent>
                    </Message>
                  </div>
                );
              })}
            </ConversationContent>
            <ConversationScrollButton />
          </Conversation>

          // ...
        </div>
      );
    }

    function renderToolOutput(part: any) {
      // ...
      switch (part.type) {
        // ...
        case "tool-sleep": { // [!code highlight]
          return ( // [!code highlight]
            <div className="space-y-2"> // [!code highlight]
              <p className="text-sm font-medium">Sleeping for {part.input.durationMs}ms...</p> // [!code highlight]
            </div> // [!code highlight]
          ); // [!code highlight]
        }
        // ...
    }

    ```
  </Step>
</Steps>

Now, try out the Flight Booking Agent again, and ask it to sleep for 10 seconds before checking any flight. You'll see the agent pause, and the UI reflect the tool call status.

## Use Cases

Aside from providing `sleep()` as a tool, there are other use cases for Agents that commonly call for suspension and resumption.

### Rate Limiting

When hitting API rate limits, use `RetryableError` with a delay:

```typescript lineNumbers
import { RetryableError } from "workflow";

async function callRateLimitedAPI(endpoint: string) {
  "use step";

  const response = await fetch(endpoint);

  if (response.status === 429) {
    const retryAfter = response.headers.get("Retry-After");
    throw new RetryableError("Rate limited", {
      retryAfter: retryAfter ? parseInt(retryAfter) * 1000 : "1m",
    });
  }

  return response.json();
}
```

## Related Documentation

* [`sleep()` API Reference](/docs/api-reference/workflow/sleep) - Full API documentation with all duration formats
* [Workflows and Steps](/docs/foundations/workflows-and-steps) - Understanding workflow context
* [Errors and Retries](/docs/foundations/errors-and-retries) - Using `RetryableError` with delays


---
title: Streaming Updates from Tools
description: Show progress updates and stream step output to users during long-running tool executions.
type: guide
summary: Write custom data parts from step functions to show progress updates during long-running tool calls.
prerequisites:
  - /docs/ai
  - /docs/foundations/streaming
related:
  - /docs/ai/defining-tools
  - /docs/ai/resumable-streams
  - /docs/api-reference/workflow/get-writable
---

# Streaming Updates from Tools



After [building a durable AI agent](/docs/ai), we already get UI message chunks for displaying tool invocations and return values. However, for long-running steps, we may want to show progress updates, or stream step output to the user while it's being generated.

Workflow SDK enables this by letting step functions write custom chunks to the same stream the agent uses. These chunks appear as data parts in your messages, which you can render however you like.

As an example, we'll extend out Flight Booking Agent to use emit more granular progress updates while searching for flights.

<Steps>
  <Step>
    ### Define Your Data Part Type

    First, define a TypeScript type for your custom data part. This ensures type safety across your tool and client code:

    ```typescript title="schemas/chat.ts" lineNumbers
    export interface FoundFlightDataPart {
      type: "data-found-flight"; // [!code highlight]
      id: string;
      data: {
        flightNumber: string;
        from: string;
        to: string;
      };
    }
    ```

    The `type` field must be a string starting with `data-` followed by your custom identifier. The `id` field should match the `toolCallId` so the client can associate the data with the correct tool invocation. Learn more about [data parts](https://ai-sdk.dev/docs/ai-sdk-ui/streaming-data#data-parts-persistent) in the AI SDK documentation.
  </Step>

  <Step>
    ### Emit Updates from Your Tool

    Use [`getWritable()`](/docs/api-reference/workflow/get-writable) inside a step function to get a handle to the stream. This is the same stream that the LLM and other tools calls are writing to, so we can inject out own data packets directly.

    {/* @skip-typecheck: incomplete code sample */}

    ```typescript title="workflows/chat/steps/tools.ts" lineNumbers
    import { getWritable } from "workflow"; // [!code highlight]
    import type { UIMessageChunk } from "ai";

    export async function searchFlights(
      { from, to, date }: { from: string; to: string; date: string },
      { toolCallId }: { toolCallId: string } // [!code highlight]
    ) {
      "use step";

      const writable = getWritable<UIMessageChunk>(); // [!code highlight]
      const writer = writable.getWriter(); // [!code highlight]

      // ... existing logic to generate flights ...

      for (const flight of generatedFlights) { // [!code highlight]

        // Simulate the time it takes to find each flight
        await new Promise((resolve) => setTimeout(resolve, 1000)); // [!code highlight]

        await writer.write({ // [!code highlight]
          id: `${toolCallId}-${flight.flightNumber}`, // [!code highlight]
          type: "data-found-flight", // [!code highlight]
          data: flight, // [!code highlight]
        }); // [!code highlight]
      } // [!code highlight]

      writer.releaseLock(); // [!code highlight]

      return {
        message: `Found ${generatedFlights.length} flights from ${from} to ${to} on ${date}`,
        flights: generatedFlights.sort((a, b) => a.price - b.price), // Sort by price
      };
    }
    ```

    Key points:

    * Call `getWritable<UIMessageChunk>()` to get the stream
    * Use `getWriter()` to acquire a writer
    * Write objects with `type`, `id`, and `data` fields
    * Always call `releaseLock()` when done writing (learn more about [streaming](/docs/foundations/streaming))
  </Step>

  <Step>
    ### Handle Data Parts in the Client

    Update your chat component to detect and render the custom data parts. Data parts are stored in the message's `parts` array alongside text and tool invocation parts:

    {/* @skip-typecheck: incomplete code sample */}

    ```typescript title="app/page.tsx" lineNumbers
    {message.parts.map((part, partIndex) => {
      // Render text parts
      if (part.type === "text") {
        return (
          <Response key={`${message.id}-text-${partIndex}`}>
            {part.text}
          </Response>
        );
      }

      // Render streaming flight data parts // [!code highlight]
      if (part.type === "data-found-flight") { // [!code highlight]
        const flight = part.data as { // [!code highlight]
          flightNumber: string; // [!code highlight]
          airline: string; // [!code highlight]
          from: string; // [!code highlight]
          to: string; // [!code highlight]
        }; // [!code highlight]
        return ( // [!code highlight]
          <div key={`${part.id}-${flight.flightNumber}`} className="p-3 bg-muted rounded-md"> // [!code highlight]
            <div className="font-medium">{flight.airline} - {flight.flightNumber}</div> // [!code highlight]
            <div className="text-muted-foreground">{flight.from} → {flight.to}</div> // [!code highlight]
          </div> // [!code highlight]
        ); // [!code highlight]
      } // [!code highlight]

      // ... other rendering logic ...
    })}
    ```

    The pattern is:

    1. Data parts have a `type` field starting with `data-`
    2. Match the type to your custom identifier (e.g., `data-found-flight`)
    3. Use the data part's payload to display progress or intermediate results
  </Step>
</Steps>

Now, when you run the agent to search for flights, you'll see the flight results pop up one after another. This will be most useful if you have tool calls that take minutes to complete, and you need to show granular progress updates to the user.

## Related Documentation

* [Building Durable AI Agents](/docs/ai) - Complete guide to durable agents
* [`getWritable()` API Reference](/docs/api-reference/workflow/get-writable) - Stream API details
* [Streaming](/docs/foundations/streaming) - Understanding workflow streams


---
title: Changelog
description: Latest updates and new features in Workflow SDK.
type: overview
---

# Changelog



# Changelog

Stay up to date with the latest changes to Workflow SDK.

***

## 2026

* Serializable AbortController and AbortSignal — March 12, 2026


---
title: Resilient run start
description: Overhaul run start logic to tolerate world storage unavailability, as long as the queue is healthy, and significantly speeds up run start.
---

# Resilient run start



# Resilient `start()`

## Motivation

When `world` storage is unavailable but the queue is up, `start()` previously failed entirely because `world.events.create(run_created)` is called before `world.queue()`. This change decouples run creation from queue dispatch so that runs can still be accepted when storage is degraded.

Additionally, the runtime previously called `world.runs.get(runId)` before `run_started`, adding an extra round-trip. By always calling `run_started` directly, we save that round-trip and can return pre-loaded events in the response to skip the initial `events.list` call, reducing TTFB.

## Design

### `start()` changes

* `world.events.create` (run\_created) and `world.queue` are now called **in parallel** via `Promise.allSettled`.
* If `events.create` errors with **429 or 5xx**, we log a warning saying that run creation failed but the run was accepted — creation will be re-tried async by the runtime when it processes the queue message. The returned `Run` instance is marked with `resilientStart = true`.
* If `events.create` errors with **409** (EntityConflictError), the run already exists (e.g., the queue handler's resilient start path created it first due to a cold-start race). This is treated as success.
* If `world.queue` fails, we still throw — the run truly failed and was not enqueued.
* The queue invocation now receives all the run inputs (`input`, `deploymentId`, `workflowName`, `specVersion`, `executionContext`) via `runInput` so the runtime can create the run later if needed.
* When the runtime re-enqueues itself, it does **not** pass these inputs — only the first queue cycle carries them.

### `workflowEntrypoint` changes

* When calling `world.events.create` with `run_started`, we now also always pass the run input that was sent through the queue, if available. The world is responsible for creating the run if it doesn't already exist.

### `Run.returnValue` polling

* When `resilientStart` is true on the Run instance (run\_created failed), the `pollReturnValue` loop retries on `WorkflowRunNotFoundError` up to 3 times (1s + 3s + 6s = 10s total) to give the queue time to deliver and the runtime to create the run via `run_started`.
* When `resilientStart` is false (normal path), 404 fails immediately — no delay for the common case of a wrong run ID.

### World contract changes

* Posting `run_started` to a **non-existent** run is now allowed when the run input is sent along with the payload. The world creates a `run_created` event first (so the event log is consistent), then creates the `run_started` event normally.
* When `run_started` encounters an **already-running** run, all worlds return `{ run }` with `event: undefined` instead of throwing. No duplicate event is created.

### Queue transport changes

`Uint8Array` values (the serialized workflow input in `runInput`) don't survive plain JSON serialization. Each world uses a transport that preserves binary data:

* **world-vercel**: CBOR transport — CBOR-encodes the entire queue payload into a `Buffer` and uses `BufferTransport` from `@vercel/queue`. Uint8Array survives natively.
* **world-local**: `TypedJsonTransport` — encodes Uint8Array as `{ __type: 'Uint8Array', data: '<base64>' }`.
* **world-postgres**: Inline typed JSON transport — same tagged-envelope approach as world-local.

## Decisions

1. **Parallel not sequential**: We chose `Promise.allSettled` over sequential calls to minimize latency in the happy path.

2. **Already-running returns run without event**: When `run_started` encounters an already-running run, all worlds return `{ run }` with `event: undefined` (no `events` array) instead of throwing. The runtime detects this by checking for `result.event === undefined`. This avoids an extra `world.runs.get` round-trip.

3. **Events in 200 response**: We only return events on the 200 path (first caller). On the already-running path, we fall back to the normal `events.list` call. This is correct because only on 200 can we be certain we know the full event history.

4. **Conditional 404 retry on Run.returnValue**: Only when `resilientStart = true` (run\_created failed). Normal runs fail fast on 404.

## Known concerns

### Cold-start race on Vercel

On Vercel, the parallel dispatch can cause the queue message to be processed before `run_created` completes, if `run_created` hits a cold-start lambda. When this happens:

1. The runtime's resilient start path creates the run from `run_started`.
2. The original `run_created` arrives and gets 409 (EntityConflictError).
3. `start()` treats the 409 as success (the run exists).

The `resilientStart` flag is NOT set on the Run instance in this case (409 is not a retryable error), so `returnValue` fails fast on 404.

### Atomicity of run entity creation

The normal `run_created` path and the resilient start path can race on creating the run entity. In `world-local`, both paths use `writeExclusive` (O\_CREAT|O\_EXCL) — atomic at the OS level, so exactly one writer wins and the other gets EEXIST. The normal path throws `EntityConflictError` on conflict (handled by `start()` as 409); the resilient start path re-reads the run from disk on conflict.

In `world-postgres`, the resilient start path uses `onConflictDoNothing` plus a re-read on conflict for the same effect, with the same outcome on either side of the race.

The narrow crash window in `world-postgres` between the run insert and the event insert is acceptable — if the run insert succeeds but the event insert crashes, the run exists and `run_started` will still proceed normally (the event log will be missing a `run_created` entry, but the run itself is functional).


---
title: API Reference
description: Complete reference for all Workflow SDK functions and primitives by package.
type: overview
summary: Browse all available functions and primitives organized by package.
---

# API Reference



All the functions and primitives that come with Workflow SDK by package.

<Cards>
  <Card title="Workflow Globals" href="/docs/api-reference/workflow-globals">
    Global APIs available inside workflow functions, including deterministic APIs, Web Platform APIs, and environment variables.
  </Card>

  <Card title="workflow" href="/docs/api-reference/workflow">
    Core workflow primitives including steps, context management, streaming, webhooks, and error handling.
  </Card>

  <Card title="workflow/api" href="/docs/api-reference/workflow-api">
    API reference for runtime functions from the `workflow/api` package.
  </Card>

  <Card title="workflow/runtime" href="/docs/api-reference/workflow-runtime">
    Runtime functions for resolving the World instance and the low-level World SDK.
  </Card>

  <Card title="workflow/observability" href="/docs/api-reference/workflow-observability">
    Utilities to hydrate step I/O, parse display names, and decrypt workflow data.
  </Card>

  <Card title="workflow/next" href="/docs/api-reference/workflow-next">
    Next.js integration for Workflow SDK that automatically configures bundling and runtime support.
  </Card>

  <Card title="workflow/nitro" href="/docs/api-reference/workflow-nitro">
    Nitro module for workflow bundling and runtime support.
  </Card>

  <Card title="workflow/nuxt" href="/docs/api-reference/workflow-nuxt">
    Nuxt module for workflow bundling and runtime support.
  </Card>

  <Card title="workflow/sveltekit" href="/docs/api-reference/workflow-sveltekit">
    SvelteKit Vite plugin for workflow bundling and runtime support.
  </Card>

  <Card title="workflow/astro" href="/docs/api-reference/workflow-astro">
    Astro integration for workflow bundling and runtime support.
  </Card>

  <Card title="workflow/vite" href="/docs/api-reference/workflow-vite">
    Standalone Vite plugin for workflow bundling and runtime support.
  </Card>

  <Card title="workflow/nest" href="/docs/api-reference/workflow-nest">
    NestJS module for workflow bundling and runtime support.
  </Card>

  <Card title="workflow/errors" href="/docs/api-reference/workflow-errors">
    Semantic error types for handling workflow storage backend failures.
  </Card>

  <Card title="@workflow/serde" href="/docs/api-reference/workflow-serde">
    Serialization symbols for custom class serialization in workflows.
  </Card>

  <Card title="@workflow/ai" href="/docs/api-reference/workflow-ai">
    Helpers for integrating AI SDK for building AI-powered workflows.
  </Card>

  <Card title="@workflow/vitest" href="/docs/api-reference/vitest">
    Vitest plugin and test helpers for integration testing workflows in-process.
  </Card>
</Cards>


---
title: Workflow Globals
description: Global APIs available inside workflow functions.
type: reference
summary: Reference of all global APIs available inside workflow functions.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/errors/node-js-module-in-workflow
  - /docs/errors/fetch-in-workflow
  - /docs/errors/timeout-in-workflow
  - /docs/how-it-works/code-transform
---

# Workflow Globals



Workflow functions run in a restricted environment that prevents access to non-deterministic or side-effecting APIs. This page lists all global APIs available inside `"use workflow"` functions.

For full Node.js runtime access, use [step functions](/docs/foundations/workflows-and-steps#step-functions).

## Deterministic APIs

These APIs are available but are **seeded or fixed** to ensure deterministic behavior across replays.

| API                                                                                                                           | Behavior                                                                              |
| ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| [`Math.random()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random)               | Seeded random number generator — same seed produces the same sequence every replay    |
| [`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) / `Date.now()` / `new Date()` | Returns a fixed timestamp that advances with the workflow's logical clock             |
| [`crypto.getRandomValues()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues)                         | Seeded — produces deterministic output for a given workflow run                       |
| [`crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID)                                   | Seeded — produces deterministic UUIDs for a given workflow run                        |
| [`crypto.subtle.digest()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest)                              | Passes through to the real implementation (SHA-256, etc. are deterministic by nature) |

<Callout type="info">
  You can safely use `Math.random()`, `Date.now()`, and `crypto.randomUUID()` in workflow functions. The framework ensures these return the same values across replays.
</Callout>

## Web Platform APIs

These standard Web APIs are available in workflow functions:

* [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers)
* [`TextEncoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder) / [`TextDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder)
* [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) / [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams)
* [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) / [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) — custom implementations with [special behavior in the workflow context](/docs/foundations/serialization#request--response). Body methods like `.json()` and `.text()` are automatically treated as step invocations.
* [`console`](https://developer.mozilla.org/en-US/docs/Web/API/console)
* [`structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone)
* [`atob`](https://developer.mozilla.org/en-US/docs/Web/API/Window/atob) / [`btoa`](https://developer.mozilla.org/en-US/docs/Web/API/Window/btoa)

## Environment Variables

`process.env` is available as a **read-only, frozen** snapshot of the environment variables at the time the workflow was started. You cannot modify it.

```typescript
export async function myWorkflow() {
  "use workflow";

  const apiKey = process.env.API_KEY; // works
  process.env.FOO = "bar"; // throws — process.env is frozen
}
```

## Binary Data

Standard JavaScript typed arrays (`Uint8Array`, `Int32Array`, `Float64Array`, etc.) are available in workflow functions.

### Base64 and hex encoding

The workflow environment provides [`Uint8Array` base64 and hex methods](https://tc39.es/proposal-arraybuffer-base64/) for encoding and decoding binary data:

{/* @skip-typecheck: polyfilled methods not available in host TypeScript */}

```typescript
// Encode to base64
const bytes = new Uint8Array([72, 101, 108, 108, 111]);
bytes.toBase64(); // "SGVsbG8="
bytes.toBase64({ alphabet: "base64url" }); // URL-safe variant
bytes.toBase64({ omitPadding: true }); // "SGVsbG8"

// Decode from base64
Uint8Array.fromBase64("SGVsbG8="); // Uint8Array([72, 101, 108, 108, 111])

// Encode to hex
bytes.toHex(); // "48656c6c6f"

// Decode from hex
Uint8Array.fromHex("48656c6c6f"); // Uint8Array([72, 101, 108, 108, 111])

// Write into an existing array
const target = new Uint8Array(5);
target.setFromBase64("SGVsbG8="); // { read: 8, written: 5 }
target.setFromHex("48656c6c6f"); // { read: 10, written: 5 }
```

<Callout type="info">
  These methods are polyfilled in the workflow environment. When the JavaScript runtime ships native support, the polyfill is automatically bypassed.
</Callout>

## Not Available

The following are **not available** in workflow functions. Move this logic to [step functions](/docs/foundations/workflows-and-steps#step-functions) instead.

* **Node.js core modules**: `fs`, `path`, `http`, `https`, `net`, `dns`, `child_process`, `cluster`, `os`, `stream`, `crypto` (Node.js version), etc. See [node-js-module-in-workflow](/docs/errors/node-js-module-in-workflow).
* **Global `fetch`**: Use [`import { fetch } from "workflow"`](/docs/api-reference/workflow/fetch) instead. See [fetch-in-workflow](/docs/errors/fetch-in-workflow).
* **Timers**: `setTimeout`, `setInterval`, `setImmediate`, and their `clear*` counterparts. Use [`sleep()`](/docs/api-reference/workflow/sleep) instead. See [timeout-in-workflow](/docs/errors/timeout-in-workflow).
* **`Buffer`**: Node.js-specific API. Use `Uint8Array` with `toBase64()` / `fromBase64()` / `toHex()` / `fromHex()` for binary data encoding, or `atob()` / `btoa()` for string-based base64.


---
title: Cookbook
description: Best-practice workflow patterns with copy-paste code examples.
type: overview
---

# Cookbook



A curated collection of workflow patterns with clean, copy-paste code examples for real use cases.

## Agent Patterns

* [**WorkflowAgent**](/cookbook/agent-patterns/durable-agent) — Build durable, resumable AI agents with AI SDK's WorkflowAgent
* [**Human-in-the-Loop**](/cookbook/agent-patterns/human-in-the-loop) — Pause an agent for human approval, then resume based on the decision
* [**Agent Cancellation**](/cookbook/agent-patterns/agent-cancellation) — Stop a running agent immediately via `run.cancel()` or gracefully via a hook + `Promise.race`

## Common Patterns

* [**Sequential & Parallel Execution**](/cookbook/common-patterns/sequential-and-parallel) — Compose steps with `await`, `Promise.all`, and `Promise.race` against durable sleeps and webhooks
* [**Workflow Composition**](/cookbook/common-patterns/workflow-composition) — Call workflows from other workflows by direct await or background spawn via `start()`
* [**Saga**](/cookbook/common-patterns/saga) — Coordinate multi-step transactions with automatic rollback when a step fails
* [**Batching**](/cookbook/common-patterns/batching) — Process large collections in parallel batches with failure isolation
* [**Rate Limiting**](/cookbook/common-patterns/rate-limiting) — Handle 429 responses and transient failures with RetryableError and backoff
* [**Scheduling**](/cookbook/common-patterns/scheduling) — Use durable sleep to schedule actions minutes, hours, or weeks ahead
* [**Timeouts**](/cookbook/common-patterns/timeouts) — Add deadlines to slow steps, hooks, and webhooks by racing them against a durable sleep
* [**Idempotency**](/cookbook/common-patterns/idempotency) — Ensure side effects and duplicate starts are safe to retry
* [**Webhooks**](/cookbook/common-patterns/webhooks) — Receive HTTP callbacks from external services and process them durably

## Integrations

* [**AI SDK**](/cookbook/integrations/ai-sdk) — Use streamText() directly inside a workflow for lower-level control over model calls and tool execution
* [**Chat SDK**](/cookbook/integrations/chat-sdk) — Build durable chat sessions with workflow persistence and AI SDK chat primitives
* [**Sandbox**](/cookbook/integrations/sandbox) — Orchestrate Vercel Sandbox lifecycle inside durable workflows

## Advanced

* [**Child Workflows**](/cookbook/advanced/child-workflows) — Spawn and orchestrate child workflows from a parent
* [**Distributed Abort Controller**](/cookbook/advanced/distributed-abort-controller) — Build a cross-process abort controller using workflow streams and hooks
* [**Upgrading Workflows**](/cookbook/advanced/upgrading-workflows) — Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward
* [**Serializable Steps**](/cookbook/advanced/serializable-steps) — Wrap non-serializable third-party objects so they cross the workflow boundary
* [**Publishing Libraries**](/cookbook/advanced/publishing-libraries) — Ship npm packages that export reusable workflow functions


---
title: Building a World
description: Implement the World interface to run workflows on any custom infrastructure.
type: guide
summary: Build a custom World adapter to run workflows on your own infrastructure.
prerequisites:
  - /docs/deploying
  - /docs/foundations/workflows-and-steps
related:
  - /docs/deploying/world/local-world
  - /docs/deploying/world/postgres-world
  - /docs/deploying/world/vercel-world
---

# Building a World



A **World** is the abstraction that allows workflows to run on any infrastructure. It handles workflow storage, step execution queuing, and data streaming. This guide explains the World interface and how to implement your own.

<Callout>
  Before building a custom World, check the [Worlds Ecosystem](/worlds) page — there may already be a community implementation for your infrastructure.
</Callout>

<Callout type="info">
  **Reference Implementation:** The [Postgres World source code](https://github.com/vercel/workflow/tree/main/packages/world-postgres) is a production-ready example of how to implement the World interface with a database backend and graphile-worker for queuing.
</Callout>

## What is a World?

A World connects workflows to the infrastructure that powers them. The World interface abstracts three core responsibilities:

1. **Storage** — Persisting workflow runs, steps, hooks, and the event log
2. **Queue** — Enqueuing and processing workflow and step invocations
3. **Streamer** — Managing real-time data streams between workflows and clients

{/* @skip-typecheck - interface definition, not runnable code */}

```typescript
interface World extends Storage, Queue, Streamer {
  start?(): Promise<void>;
  close?(): Promise<void>;
  getEncryptionKeyForRun?(run: WorkflowRun): Promise<Uint8Array | undefined>;
  getEncryptionKeyForRun?(runId: string, context?: Record<string, unknown>): Promise<Uint8Array | undefined>;
}
```

The optional `start()` method initializes background tasks (for example, queue polling). The optional `close()` method releases resources like connection pools and listeners. The optional `getEncryptionKeyForRun()` method returns the AES-256 key used to encrypt data for a run; if it is not implemented, encryption is disabled.

## The Event Log Model

Workflow storage is built on an **append-only event log**. All state changes happen through events — you never modify runs, steps, or hooks directly. Instead, you create events that update the materialized state.

Events fall into three categories: run lifecycle events, step lifecycle events, and hook lifecycle events. See the [Event Sourcing](/docs/how-it-works/event-sourcing) documentation for a complete list of event types and their semantics.

## Storage Interface

The Storage interface provides read access to materialized entities and write access through events:

{/* @skip-typecheck - interface definition, not runnable code */}

```typescript
interface Storage {
  runs: {
    get(id: string, params?: GetWorkflowRunParams): Promise<WorkflowRun>;
    list(params?: ListWorkflowRunsParams): Promise<PaginatedResponse<WorkflowRun>>;
  };

  steps: {
    get(runId: string | undefined, stepId: string, params?: GetStepParams): Promise<Step>;
    list(params: ListWorkflowRunStepsParams): Promise<PaginatedResponse<Step>>;
  };

  events: {
    // Create a new workflow run (runId may be client-provided or null for server generation)
    create(runId: string | null, data: RunCreatedEventRequest, params?: CreateEventParams): Promise<EventResult>;
    
    // Create an event for an existing run
    create(runId: string, data: CreateEventRequest, params?: CreateEventParams): Promise<EventResult>;
    
    list(params: ListEventsParams): Promise<PaginatedResponse<Event>>;
    listByCorrelationId(params: ListEventsByCorrelationIdParams): Promise<PaginatedResponse<Event>>;
  };

  hooks: {
    get(hookId: string, params?: GetHookParams): Promise<Hook>;
    getByToken(token: string, params?: GetHookParams): Promise<Hook>;
    list(params: ListHooksParams): Promise<PaginatedResponse<Hook>>;
  };
}
```

### Key Implementation Details

**Event Creation:** When `events.create()` is called, your implementation must:

1. Persist the event to the event log
2. Atomically update the affected entity (run, step, or hook)
3. Return both the created event and the updated entity

**Run Creation:** For `run_created` events, the `runId` parameter may be a client-provided string or `null`. When `null`, your World generates and returns a new `runId`.

**Hook Tokens:** Hook tokens must be unique. If a `hook_created` event conflicts with an existing token, return a `hook_conflict` event instead and include the active hook owner's run ID as `eventData.conflictingRunId`.

**Automatic Hook Disposal:** When a workflow reaches a terminal state (`completed`, `failed`, or `cancelled`), automatically dispose of all associated hooks to release tokens for reuse.

## Queue Interface

The Queue interface handles asynchronous execution of workflows and steps:

{/* @skip-typecheck - interface definition, not runnable code */}

```typescript
interface Queue {
  getDeploymentId(): Promise<string>;

  queue(
    queueName: ValidQueueName,
    message: QueuePayload,
    opts?: QueueOptions
  ): Promise<{ messageId: MessageId }>;

  createQueueHandler(
    queueNamePrefix: QueuePrefix,
    handler: (message: unknown, meta: { attempt: number; queueName: ValidQueueName; messageId: MessageId }) => Promise<void | { timeoutSeconds: number }>
  ): (req: Request) => Promise<Response>;
}
```

### Queue Names

Queue names follow a specific pattern:

* `__wkf_workflow_<name>` — For workflow invocations
* `__wkf_step_<name>` — For step invocations

### Message Payloads

Two types of messages flow through queues:

**Workflow Invocations:**

{/* @skip-typecheck - interface definition, not runnable code */}

```typescript
interface WorkflowInvokePayload {
  runId: string;
  traceCarrier?: Record<string, string>;  // OpenTelemetry context
  requestedAt?: Date;
}
```

**Step Invocations:**

{/* @skip-typecheck - interface definition, not runnable code */}

```typescript
interface StepInvokePayload {
  workflowName: string;
  workflowRunId: string;
  workflowStartedAt: number;
  stepId: string;
  traceCarrier?: Record<string, string>;
  requestedAt?: Date;
}
```

### Implementation Considerations

* Messages must be delivered at-least-once
* Support configurable retry policies
* Track attempt counts for observability
* Implement idempotency using the `idempotencyKey` option when provided

## Streamer Interface

The Streamer interface enables real-time data streaming:

{/* @skip-typecheck - interface definition, not runnable code */}

```typescript
interface Streamer {
  streamFlushIntervalMs?: number;

  streams: {
    write(
      runId: string,
      name: string,
      chunk: string | Uint8Array
    ): Promise<void>;

    writeMulti?(
      runId: string,
      name: string,
      chunks: (string | Uint8Array)[]
    ): Promise<void>;

    close(runId: string, name: string): Promise<void>;

    get(
      runId: string,
      name: string,
      startIndex?: number
    ): Promise<ReadableStream<Uint8Array>>;

    list(runId: string): Promise<string[]>;

    /** Paginated snapshot of stream chunks. */
    getChunks(
      runId: string,
      name: string,
      options?: { limit?: number; cursor?: string }
    ): Promise<{
      data: { index: number; data: Uint8Array }[];
      cursor: string | null;
      hasMore: boolean;
      done: boolean;
    }>;

    /** Lightweight metadata: tail index and completion flag. */
    getInfo(
      runId: string,
      name: string
    ): Promise<{ tailIndex: number; done: boolean }>;
  };
}
```

Streams are identified by a combination of `runId` and `name`. Each workflow run can have multiple named streams.
`writeMulti()` is an optional optimization for batching multiple writes.

`getChunks` returns a paginated snapshot of currently available chunks (unlike `get` which returns a live `ReadableStream` that waits for new chunks). `getInfo` returns the tail index (last chunk index, 0-based, or `-1` when empty) and whether the stream is complete — useful for resolving negative `startIndex` values into absolute positions.

## Reference Implementations

Study these implementations for guidance:

* **[Local World](https://github.com/vercel/workflow/tree/main/packages/world-local)** — Filesystem-based, great for understanding the basics
* **[Postgres World](https://github.com/vercel/workflow/tree/main/packages/world-postgres)** — Database-backed with graphile-worker for queuing

## Testing Your World

Workflow SDK includes an E2E test suite that validates World implementations. Once your World is published to npm:

1. Add your world to [`worlds-manifest.json`](https://github.com/vercel/workflow/blob/main/worlds-manifest.json)
2. Open a PR to the Workflow repository
3. CI will automatically run the E2E test suite against your implementation

Your world will then appear on the [Worlds Ecosystem](/worlds) page with its compatibility status and performance benchmarks.

## Publishing Your World

1. **Package your World** — Export a default World instance from your package
2. **Publish to npm** — Publish your package to npm
3. **Add to the manifest** — Submit a PR adding your world to [`worlds-manifest.json`](https://github.com/vercel/workflow/blob/main/worlds-manifest.json)
4. **Document configuration** — Clearly document any required environment variables

```json
// worlds-manifest.json entry
{
  "package": "your-world-package",
  "repository": "https://github.com/you/your-world",
  "docs": "https://github.com/you/your-world#readme"
}
```


---
title: Deploying
description: Deploy workflows locally, on Vercel, or anywhere using pluggable World adapters.
type: overview
summary: Learn how to deploy workflows to different environments using World adapters.
related:
  - /docs/deploying/world/local-world
  - /docs/deploying/world/postgres-world
  - /docs/deploying/world/vercel-world
  - /docs/deploying/building-a-world
---

# Deploying



Workflows are designed to be highly portable. The same workflow code can run locally during development, on Vercel with zero configuration, or on any infrastructure using **Worlds** — pluggable adapters that handle storage, queuing, and communication.

## Local Development

During local development, workflows automatically use the **Local World** — no configuration required. The Local World stores workflow data in a `.workflow-data/` directory and processes steps synchronously, making it perfect for development and testing.

```bash
# Just run your dev server - workflows work out of the box
npm run dev
```

You can inspect local workflow data using the CLI:

```bash
npx workflow inspect runs
```

<Callout>
  Learn more about the [Local World](/worlds/local) configuration and internals.
</Callout>

## Deploying to Vercel

The easiest way to deploy workflows to production is on Vercel. When you deploy to Vercel, workflows automatically use the **Vercel World** — again, with zero configuration.

The Vercel World provides:

* **Durable storage** - Workflow state persists across function invocations
* **Managed queuing** - Steps are processed reliably with automatic retries
* **Automatic scaling** - Workflows scale with your application
* **Built-in observability** - View workflow runs in the Vercel dashboard

Simply deploy your application:

```bash
vercel deploy
```

<FluidComputeCallout />

<Callout>
  Learn more about the [Vercel World](/worlds/vercel) and its capabilities.
</Callout>

## Self-Hosting & Other Providers

For self-hosting or deploying to other cloud providers, you can use community-maintained Worlds or build your own.

<Cards>
  <Card title="Explore Worlds" href="/worlds">
    Browse official and community World implementations with compatibility status and performance benchmarks.
  </Card>

  <Card title="Build Your Own" href="/docs/deploying/building-a-world">
    Learn how to implement a custom World for your infrastructure.
  </Card>
</Cards>

### Using a Third-Party World

To use a different World implementation, set the `WORKFLOW_TARGET_WORLD` environment variable:

```bash
export WORKFLOW_TARGET_WORLD=@workflow/world-postgres
# Plus any world-specific configuration
export DATABASE_URL=postgres://...
```

Each World may have its own configuration requirements — refer to the specific World's documentation for details.

## Observability

The [Observability tools](/docs/observability) work with any World backend. By default they connect to your local environment, but can be configured to inspect remote deployments:

```bash
# Inspect local workflows
npx workflow inspect runs

# Inspect remote workflows
npx workflow inspect runs --backend @workflow/world-postgres
```

Learn more about [Observability](/docs/observability) tools.


---
title: corrupted-event-log
description: The workflow's event log contains an event that no consumer can process, indicating corruption or invalid state.
type: troubleshooting
summary: Resolve corrupted event log errors caused by duplicate or orphaned events.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/foundations/errors-and-retries
---

# corrupted-event-log



This error occurs when the Workflow runtime repeatedly cannot replay events in the event log. This usually means the event log is in an invalid state, such as duplicate or orphaned events, or that a runtime determinism bug persists across retry attempts.

This is a **workflow-level fatal error**. It cannot be caught or handled inside your workflow code. The runtime first retries transient replay divergence automatically; it marks the run as failed with this error only after replay still cannot recover.

## Error Message

```
Workflow replay diverged <divergenceCount> times after <maxRecoveryReplays> recovery replays; latest divergent event was <eventId>. Last divergence: <details>
```

## Why This Happens

Workflows persist their progress as an ordered event log. During replay, the runtime processes each event in sequence — every event must be consumed by a matching callback (e.g., a step or sleep waiting for its result). When an event has no matching consumer, the runtime cannot advance past it, which would block all subsequent events and hang the workflow indefinitely.

Instead of silently hanging, the runtime retries a divergent replay before failing the workflow and surfacing this terminal error.

Common scenarios that produce this error:

1. **Duplicate completion events** — Two `wait_completed` events for a single `wait_created`, or two `step_completed` events for the same step. The first is consumed normally, but the second has no consumer.
2. **Orphaned events** — A `step_completed` or `wait_completed` event whose `correlationId` doesn't match any step or sleep in the workflow code.
3. **Events after terminal state** — An event that arrives after its corresponding step or wait has already reached a terminal state (e.g., `step_retrying` after `step_completed`).

## What To Do

This error indicates a bug in the Workflow SDK or Workflow server — not in your workflow code. Your workflow code does not need to change. Follow these steps to resolve the issue:

### 1. Upgrade to the latest `workflow` package

The bug that caused the corrupted event log may have already been identified and fixed in a newer version. Update to the latest version:

```bash
npm install workflow@latest
```

### 2. Retry the failed run

If this error is displayed, automatic replay recovery has already been exhausted and the run has been marked as `failed`. You can re-run it using the **Re-run** button in the Workflow Dashboard.

### 3. Report the issue

If the error persists after upgrading, please [open an issue on GitHub](https://github.com/vercel/workflow/issues/new) so we can investigate and fix the underlying bug. Include the following details to help us diagnose the problem:

* The version of the `workflow` package you are using
* The run ID(s) of the affected workflow run(s)
* The error message (including `eventType`, `correlationId`, and `eventId`)
* Any details about the event log or the workflow that triggered the error

## This Error Cannot Be Caught

Unlike other workflow errors, a corrupted event log error is **not catchable** inside your workflow function. Because the event log itself is invalid, the runtime cannot safely continue executing any user code. The entire run fails immediately and is marked as `failed`.

To handle this programmatically from outside the workflow, you can check the run status:

```typescript lineNumbers
import { getRun } from "workflow/api";

const run = getRun("wrun_abc123");
const status = await run.status;
if (status === "failed") {
  console.error("Run failed");
}
```


---
title: fetch-in-workflow
description: Use the workflow fetch step function instead of global fetch in workflows.
type: troubleshooting
summary: Resolve the fetch-in-workflow error by using the workflow fetch function.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/api-reference/workflow/fetch
---

# fetch-in-workflow



This error occurs when you try to use `fetch()` directly in a workflow function, or when a library (like the AI SDK) tries to call `fetch()` under the hood.

## Error Message

```
Global "fetch" is unavailable in workflow functions. Use the "fetch" step function from "workflow" to make HTTP requests.
```

## Why This Happens

Workflow functions run in a sandboxed environment without direct access to `fetch()`.

Many libraries make HTTP requests under the hood. For example, the AI SDK's `generateText()` function calls `fetch()` to make HTTP requests to AI providers. When these libraries run inside a workflow function, they fail because the global `fetch` is not available.

## Quick Fix

Import the `fetch` step function from the `workflow` package and assign it to `globalThis.fetch` inside your workflow function. This version of `fetch` is a step function that wraps the standard `fetch` API, automatically handling serialization and providing retry capabilities. This will also make `fetch()` available to all functions and libraries in the current workflow function.

**Before:**

```typescript lineNumbers title="workflows/ai.ts"
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";

export async function chatWorkflow(prompt: string) {
  "use workflow";

  // Error - generateText() calls fetch() under the hood
  const result = await generateText({ // [!code highlight]
    model: openai("gpt-4"), // [!code highlight]
    prompt, // [!code highlight]
  }); // [!code highlight]

  return result.text;
}
```

**After:**

```typescript lineNumbers title="workflows/ai.ts"
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import { fetch } from "workflow"; // [!code highlight]

export async function chatWorkflow(prompt: string) {
  "use workflow";

  globalThis.fetch = fetch; // [!code highlight]

  // Now generateText() can make HTTP requests via the fetch step
  const result = await generateText({
    model: openai("gpt-4"),
    prompt,
  });

  return result.text;
}
```

## Common Scenarios

### AI SDK Integration

This is the most common scenario - using AI SDK functions that make HTTP requests:

```typescript lineNumbers
import { generateText, streamText } from "ai";
import { openai } from "@ai-sdk/openai";
import { fetch } from "workflow"; // [!code highlight]

export async function aiWorkflow(userMessage: string) {
  "use workflow";

  globalThis.fetch = fetch; // [!code highlight]

  // generateText makes HTTP requests to OpenAI
  const response = await generateText({
    model: openai("gpt-4"),
    prompt: userMessage,
  });

  return response.text;
}
```

### Direct API Calls

You can also use the fetch step function directly for your own HTTP requests:

```typescript lineNumbers
import { fetch } from "workflow";

export async function dataWorkflow() {
  "use workflow";

  // Use fetch directly for HTTP requests
  const response = await fetch("https://api.example.com/data"); // [!code highlight]
  const data = await response.json();

  return data;
}
```

For more details on the `fetch` step function, see the [fetch API reference](/docs/api-reference/workflow/fetch).


---
title: hook-conflict
description: Hook tokens must be unique across all running workflows in your project.
type: troubleshooting
summary: Resolve hook token conflicts by using unique or auto-generated tokens.
prerequisites:
  - /docs/foundations/hooks
related:
  - /docs/api-reference/workflow/create-hook
  - /docs/api-reference/workflow/define-hook
---

# hook-conflict



This error occurs when you try to create a hook with a token that is already in use by another active workflow run. Hook tokens must be unique across all running workflows in your project.

## Error Message

```
Hook token "<token>" is already in use by another workflow
```

## Why This Happens

Hooks use tokens to identify incoming webhook payloads. When you create a hook with `createHook({ token: "my-token" })`, the Workflow runtime reserves that token for your workflow run. If another workflow run is already using that token, a conflict occurs.

This typically happens when:

1. **Two workflows start simultaneously** with the same hardcoded token
2. **A previous workflow run is still waiting** for a hook when a new run tries to use the same token

## Common Causes

### Hardcoded Token Values

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
// Error - multiple concurrent runs will conflict
export async function processPayment() {
  "use workflow";

  const hook = createHook({ token: "payment-hook" }); // [!code highlight]
  // If another run is already waiting on "payment-hook", this will fail
  const payment = await hook;
}
```

**Solution:** Use unique tokens that include the run ID or other unique identifiers.

```typescript lineNumbers
import { createHook } from "workflow";

export async function processPayment(orderId: string) {
  "use workflow";

  // Include unique identifier in token
  const hook = createHook({ token: `payment-${orderId}` }); // [!code highlight]
  const payment = await hook;
}
```

### Omitting the Token (Auto-generated)

The safest approach is to let the Workflow runtime generate a unique token automatically:

```typescript lineNumbers
import { createHook } from "workflow";

export async function processPayment() {
  "use workflow";

  const hook = createHook(); // Auto-generated unique token // [!code highlight]
  console.log(`Send webhook to token: ${hook.token}`);
  const payment = await hook;
}
```

## Handling Hook Conflicts

When a hook conflict occurs, awaiting the hook will throw a `HookConflictError`. The error exposes the token that conflicted and, for current worlds, the run ID that currently owns it. `conflictingRunId` remains optional for compatibility with older persisted events and world implementations, so guard it before delegating:

```typescript lineNumbers
import { createHook } from "workflow";
import { HookConflictError } from "@workflow/errors";

export async function processPayment(orderId: string) {
  "use workflow";

  const hook = createHook({ token: `payment-${orderId}` });

  try {
    const payment = await hook; // [!code highlight]
    return { success: true, payment };
  } catch (error) {
    if (HookConflictError.is(error)) { // [!code highlight]
      // Another workflow is already processing this order
      console.log(`Conflicting token: ${error.token}`);
      if (error.conflictingRunId) {
        console.log(`Active run: ${error.conflictingRunId}`);
      }
      return {
        success: false,
        reason: "duplicate-processing",
        token: error.token,
        runId: error.conflictingRunId
      };
    }
    throw error; // Re-throw other errors
  }
}
```

This pattern is useful when you want to detect duplicate processing inside the workflow. Runtime APIs such as `resumeHook()` and `getRun()` must be called outside workflow functions, for example from an API route or in a step.

### Delegate to the Active Run

In idempotency flows, a conflict means another active run already owns the hook token. You can return the duplicate-processing payload from the workflow, resume the active hook to deliver the payload to the existing run, then use `getRun(result.runId)` to wait for, stream, or cancel the active run:

```typescript lineNumbers
import { getRun, resumeHook, start } from "workflow/api";
import { processPayment } from "@/workflows/process-payment";

type ProcessPaymentResult =
  | { success: true; payment: unknown }
  | {
      success: false;
      reason: "duplicate-processing";
      token: string;
      runId?: string;
    };

export async function POST(request: Request) {
  const { orderId, payment } = await request.json();
  const run = await start(processPayment, [orderId]);
  const result = (await run.returnValue) as ProcessPaymentResult;

  if (
    result.success === false &&
    result.reason === "duplicate-processing" &&
    result.runId
  ) {
    await resumeHook(result.token, payment); // [!code highlight]
    const activeRun = getRun(result.runId); // [!code highlight]

    return Response.json({
      delegatedToRunId: activeRun.runId,
      result: await activeRun.returnValue
    });
  }

  return Response.json(result);
}
```

If the caller needs live output instead of the final result, return `activeRun.getReadable()` from the same branch. If the duplicate request should replace the active work, call `await activeRun.cancel()` after inspecting the run.

## When Hook Tokens Are Released

Hook tokens are automatically released when:

* The workflow run **completes** (successfully or with an error)
* The workflow run is **cancelled**
* The hook is explicitly **disposed**

After a workflow completes, its hook tokens become available for reuse by other workflows.

## Best Practices

1. **Use auto-generated tokens** when possible - they are guaranteed to be unique
2. **Include unique identifiers** if you need custom tokens (order ID, user ID, etc.)
3. **Avoid reusing the same token** across multiple concurrent workflow runs
4. **Consider using webhooks** (`createWebhook`) if you need a fixed, predictable URL that can receive multiple payloads

## Related

* [Hooks](/docs/foundations/hooks) - Learn more about using hooks in workflows
* [getRun](/docs/api-reference/workflow-api/get-run) - Retrieve or control the active run
* [resumeHook](/docs/api-reference/workflow-api/resume-hook) - Deliver data to the active hook
* [createWebhook](/docs/api-reference/workflow/create-webhook) - Alternative for fixed webhook URLs


---
title: Errors
description: Fix common mistakes when creating and executing workflows.
type: overview
summary: Browse and resolve common workflow errors.
related:
  - /docs/foundations/errors-and-retries
---

# Errors



Fix common mistakes when creating and executing workflows in the **Workflow SDK**.

<Cards>
<Card href="/docs/errors/corrupted-event-log" title="corrupted-event-log">The workflow's event log contains an event that no consumer can process, indicating corruption or invalid state.</Card>
<Card href="/docs/errors/fetch-in-workflow" title="fetch-in-workflow">Use the workflow fetch step function instead of global fetch in workflows.</Card>
<Card href="/docs/errors/hook-conflict" title="hook-conflict">Hook tokens must be unique across all running workflows in your project.</Card>
<Card href="/docs/errors/node-js-module-in-workflow" title="node-js-module-in-workflow">Move Node.js core module usage to step functions instead of workflows.</Card>
<Card href="/docs/errors/replay-divergence" title="replay-divergence">A workflow replay temporarily followed a path that did not match its recorded events.</Card>
<Card href="/docs/errors/runtime-decryption-failed" title="runtime-decryption-failed">The SDK's built-in AES-GCM encryption layer failed to encrypt or decrypt a workflow payload.</Card>
<Card href="/docs/errors/serialization-failed" title="serialization-failed">Ensure all data passed between workflow and step functions is serializable.</Card>
<Card href="/docs/errors/start-invalid-workflow-function" title="start-invalid-workflow-function">The function passed to start() must be a transformed workflow function.</Card>
<Card href="/docs/errors/step-executed-multiple-times" title="Step executed multiple times">A step ran more than once because its function invocation crashed before it could report a result.</Card>
<Card href="/docs/errors/step-not-registered" title="step-not-registered">A step function is not registered in the current deployment.</Card>
<Card href="/docs/errors/timeout-in-workflow" title="timeout-in-workflow">Use the sleep function instead of setTimeout or setInterval in workflows.</Card>
<Card href="/docs/errors/webhook-invalid-respond-with-value" title="webhook-invalid-respond-with-value">The respondWith option must be "manual" or a Response object.</Card>
<Card href="/docs/errors/webhook-response-not-sent" title="webhook-response-not-sent">Manual webhooks must send a response before execution completes.</Card>
<Card href="/docs/errors/workflow-not-registered" title="workflow-not-registered">A workflow function is not registered in the current deployment.</Card>
</Cards>

## Learn More

* [API Reference](/docs/api-reference) - Complete API documentation
* [Foundations](/docs/foundations) - Architecture and core concepts
* [Examples](https://github.com/vercel/workflow) - Sample implementations
* [GitHub Issues](https://github.com/vercel/workflow/issues) - Report bugs and request features


---
title: node-js-module-in-workflow
description: Move Node.js core module usage to step functions instead of workflows.
type: troubleshooting
summary: Resolve the node-js-module-in-workflow error by moving Node.js modules to step functions.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/how-it-works/understanding-directives
---

# node-js-module-in-workflow



This error occurs when you try to import or use Node.js core modules (like `fs`, `http`, `crypto`, `path`, etc.) directly inside a workflow function.

## Error Message

```
Cannot use Node.js module "fs" in workflow functions. Move this module to a step function.
```

## Why This Happens

Workflow functions run in a sandboxed environment without full Node.js runtime access. This restriction is important for maintaining **determinism** - the ability to replay workflows exactly and resume from where they left off after suspensions or failures.

Node.js modules have side effects and non-deterministic behavior that could break workflow replay guarantees.

## Quick Fix

Move any code using Node.js modules to a step function. Step functions have full Node.js runtime access.

For example, when trying to read a file in a workflow function, you should move the code to a step function.

**Before:**

```typescript lineNumbers
import * as fs from "fs";

export async function processFileWorkflow(filePath: string) {
  "use workflow";

  // This will cause an error - Node.js module in workflow context
  const content = fs.readFileSync(filePath, "utf-8"); // [!code highlight]
  return content;
}
```

**After:**

```typescript lineNumbers
import * as fs from "fs";

export async function processFileWorkflow(filePath: string) {
  "use workflow";

  // Call step function that has Node.js access
  const content = await read(filePath); // [!code highlight]
  return content;
}

async function read(filePath: string) {
  "use step";

  // Node.js modules are allowed in step functions
  return fs.readFileSync(filePath, "utf-8"); // [!code highlight]
}
```

## Common Node.js Modules

These common Node.js core modules cannot be used in workflow functions:

* File system: `fs`, `path`
* Network: `http`, `https`, `net`, `dns`
* Process: `child_process`, `cluster`
* Crypto: `crypto` (use Web Crypto API instead)
* Operating system: `os`
* Streams: `stream` (use Web Streams API instead)

<Callout type="info">
  You can use Web Platform APIs in workflow functions (like `Headers`, `crypto.randomUUID()`, `Response`, etc.), since these are available in the sandboxed environment. See [Workflow Globals](/docs/api-reference/workflow-globals) for the full list.
</Callout>


---
title: replay-divergence
description: A workflow replay temporarily followed a path that did not match its recorded events.
type: troubleshooting
summary: Understand automatic recovery when a workflow replay diverges from its event history.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/errors/corrupted-event-log
  - /docs/foundations/errors-and-retries
---

# replay-divergence



A replay divergence occurs when one invocation of a workflow cannot consume the durable event history using the promises, hooks, sleeps, or steps it created during replay.

This is an SDK/runtime signal, not an error thrown by your workflow code. It is not catchable inside a workflow function.

## Automatic Recovery

A single divergent replay does not prove that persisted history is corrupted. For example, asynchronous delivery ordering may cause one invocation to follow the wrong side of a race while another replay can follow the recorded history correctly.

The runtime automatically queues another replay when an invocation reports `REPLAY_DIVERGENCE`. No terminal `run_failed` event is written during these recovery attempts.

If recovery replays continue to diverge after the retry budget is exhausted, the runtime marks the run as failed with `CORRUPTED_EVENT_LOG` and records the latest divergent event for diagnosis.

## What To Do

Most replay divergence signals recover without action. If a run ultimately fails with `CORRUPTED_EVENT_LOG`, update to the latest `workflow` package and report the run ID and error details if the failure persists.


---
title: runtime-decryption-failed
description: The SDK's built-in AES-GCM encryption layer failed to encrypt or decrypt a workflow payload.
type: troubleshooting
summary: Resolve runtime decryption failures caused by ciphertext corruption, key mismatch, or malformed envelopes.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/foundations/errors-and-retries
---

# runtime-decryption-failed



This error occurs when the Workflow SDK's built-in AES-GCM encryption layer fails while encrypting or decrypting a workflow payload. The SDK encrypts step inputs, step outputs, hook payloads, and other event-log data with a per-run AES-256 key whenever encryption is configured for the deployment.

This is an **internal SDK failure** — your workflow code never invokes the encryption primitives directly. When this surfaces, it means the ciphertext, nonce, or auth tag the SDK tried to verify is not the bytes that were originally produced. The run is failed with the `RUNTIME_ERROR` classification.

## Error Message

```
AES-256-GCM decryption failed: The operation failed for an operation-specific reason
```

The underlying cause is a native Web Crypto [`OperationError`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException#operationerror) — most commonly raised by `AESCipherJob.onDone` in Node's `node:internal/crypto/util` module when the GCM authentication tag does not verify.

The thrown `RuntimeDecryptionError` carries a small `context` object with diagnostic fields to help triangulate the source:

* `operation` — `'encrypt'` or `'decrypt'`
* `byteLength` — total byte length of the payload at the failure site
* `formatPrefix` — the first 4 bytes of the input (`'encr'` for a well-formed encrypted envelope, otherwise a hex dump)

## Why This Happens

Common causes, in rough order of likelihood:

1. **Ciphertext mutation or truncation in transit.** The encrypted payload reached the SDK with bytes that differ from what storage holds. Possible sources include a truncated HTTP response from a workflow-server ref endpoint, an edge-cache miss returning a partial 200, or a proxy drop during streaming. A truncated body whose first 4 bytes happen to still spell `encr` produces the exact "auth tag mismatch" symptom.
2. **Key resolution mismatch.** The key used to decrypt is not the key that was used to encrypt — e.g. the run's `deploymentId` was not threaded through key resolution and the SDK fell back to the wrong deployment's key material.
3. **Malformed encrypted envelope.** The envelope is too short to contain the GCM nonce (12 bytes) and auth tag (16 bytes), so decryption is rejected before it begins.

## What To Do

This error indicates an SDK or infrastructure problem — not a bug in your workflow code. Your workflow code does not need to change.

### 1. Upgrade to the latest `workflow` package

The underlying issue may have already been identified and fixed:

```bash
npm install workflow@latest
```

### 2. Retry the failed run

Since this is a fatal error, the run is automatically marked as `failed`. You can re-run it using the **Re-run** button in the Workflow Dashboard.

### 3. Report the issue

If the error persists after upgrading, please [open an issue on GitHub](https://github.com/vercel/workflow/issues/new) so we can investigate. Include:

* The version of the `workflow` package you are using
* The run ID(s) of the affected workflow run(s)
* The full error message, including the `context` fields (`operation`, `byteLength`, `formatPrefix`)
* Whether the affected workflows make heavy use of large step inputs/outputs (which may indicate the failure is on the lazy-loaded ref read path)

## This Error Cannot Be Caught

Like other `WorkflowRuntimeError` subclasses, a runtime decryption failure is **not catchable** inside your workflow function. The runtime cannot safely continue executing user code when an event-log payload can't be verified, so the entire run fails immediately and is marked as `failed`.

To handle this programmatically from outside the workflow, check the run status:

```typescript lineNumbers
import { getRun } from "workflow/api";

const run = getRun("wrun_abc123");
const status = await run.status;
if (status === "failed") {
  console.error("Run failed");
}
```


---
title: serialization-failed
description: Ensure all data passed between workflow and step functions is serializable.
type: troubleshooting
summary: Resolve serialization errors by passing only serializable types across execution boundaries.
prerequisites:
  - /docs/foundations/serialization
related:
  - /docs/foundations/workflows-and-steps
---

# serialization-failed



This error occurs when you try to pass non-serializable data between execution boundaries in your workflow. All data passed between workflow functions, step functions, and the workflow runtime must be serializable to persist in the event log.

## Error Message

```
Failed to serialize workflow arguments. Ensure you're passing serializable types
(plain objects, arrays, primitives, Date, RegExp, Map, Set).
```

This error can appear when:

* Serializing workflow arguments when calling `start()`
* Serializing workflow return values
* Serializing step arguments
* Serializing step return values

## Why This Happens

Workflows persist their state using an event log. Every value that crosses execution boundaries must be:

1. **Serialized** to be stored in the event log
2. **Deserialized** when the workflow resumes

Functions, class instances, symbols, and other non-serializable types cannot be properly reconstructed after serialization, which would break workflow replay.

## Common Causes

### Passing Functions

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
// Error - functions cannot be serialized
export async function processWorkflow() {
  "use workflow";

  const callback = () => console.log("done"); // [!code highlight]
  await processStep(callback); // Error! // [!code highlight]
}
```

**Solution:** Pass data instead, then define the function logic in the step.

```typescript lineNumbers
// Fixed - pass configuration data instead
export async function processWorkflow() {
  "use workflow";

  await processStep({ shouldLog: true }); // [!code highlight]
}

async function processStep(config: { shouldLog: boolean }) {
  "use step";

  if (config.shouldLog) { // [!code highlight]
    console.log("done"); // [!code highlight]
  } // [!code highlight]
}
```

### Class Instances

```typescript lineNumbers
class User {
  constructor(public name: string) {}
  greet() { return `Hello ${this.name}`; }
}

// Error - class instances lose methods after serialization
export async function greetWorkflow() {
  "use workflow";

  await greetStep(new User("Alice")); // Error! // [!code highlight]
}
```

**Solution:** Pass plain objects and reconstruct the class in the step.

```typescript lineNumbers
class User {
  constructor(public name: string) {}
  greet() { return `Hello ${this.name}`; }
}

// Fixed - pass plain object, reconstruct in step
export async function greetWorkflow() {
  "use workflow";

  await greetStep({ name: "Alice" }); // [!code highlight]
}

async function greetStep(userData: { name: string }) {
  "use step";

  const user = new User(userData.name); // [!code highlight]
  console.log(user.greet());
}
```

## Supported Serializable Types

Workflow SDK supports these types across execution boundaries:

### Standard JSON Types

* `string`, `number`, `boolean`, `null`
* Arrays of serializable values
* Plain objects with serializable values

To learn more about supported types, see the [Serialization](/docs/foundations/serialization) section.

## Debugging Serialization Issues

To identify what's causing serialization to fail:

1. **Check the error stack trace** - it often shows which property failed
2. **Simplify your data** - temporarily pass smaller objects to isolate the issue
3. **Ensure you are using supported data types** - see the [Serialization](/docs/foundations/serialization) section for more details


---
title: start-invalid-workflow-function
description: The function passed to start() must be a transformed workflow function.
type: troubleshooting
summary: Fix invalid workflow function errors by adding "use workflow" and enabling your framework integration.
prerequisites:
  - /docs/foundations/starting-workflows
related:
  - /docs/api-reference/workflow-api/start
  - /docs/getting-started/next
  - /docs/api-reference/workflow-next/with-workflow
---

# start-invalid-workflow-function



This error occurs when `start()` receives a function that does not have Workflow SDK's generated workflow metadata. In practice, that usually means the function is missing `"use workflow"` or the file was never transformed by your framework integration.

## Error Message

```
'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive.
```

## Why This Happens

`start()` expects an imported workflow function, not just any async function. During compilation, Workflow SDK transforms files that contain `"use workflow"` and attaches generated metadata such as the workflow ID. If that transform never runs, or if you pass a wrapper function instead of the transformed export, `start()` cannot identify what to enqueue and throws this error.

## Common Causes

### Missing `"use workflow"`

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
import { start } from "workflow/api";

export async function sendReminder(email: string) {
  await sendEmail(email);
}

export async function POST() {
  await start(sendReminder, ["hello@example.com"]);
  return new Response("ok");
}

async function sendEmail(email: string) {
  "use step";
  console.log(`Sending email to ${email}`);
}
```

**Fix:** Add `"use workflow"` to the workflow function.

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
import { start } from "workflow/api";

export async function sendReminder(email: string) {
  "use workflow"; // [!code highlight]
  await sendEmail(email);
}

export async function POST() {
  await start(sendReminder, ["hello@example.com"]); // [!code highlight]
  return new Response("ok");
}

async function sendEmail(email: string) {
  "use step";
  console.log(`Sending email to ${email}`);
}
```

### Missing `withWorkflow()` in `next.config.ts`

{/* @skip-typecheck: incomplete code sample */}

```typescript title="next.config.ts" lineNumbers
import type { NextConfig } from "next";

const nextConfig: NextConfig = {};

export default nextConfig;
```

**Fix:** Wrap the config with `withWorkflow()` so workflow files are transformed.

```typescript title="next.config.ts" lineNumbers
import type { NextConfig } from "next";
import { withWorkflow } from "workflow/next"; // [!code highlight]

const nextConfig: NextConfig = {};

export default withWorkflow(nextConfig); // [!code highlight]
```

### Passing a wrapper function instead of the imported workflow

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
import { start } from "workflow/api";
import { sendReminder } from "./workflows/send-reminder";

export async function POST() {
  // Does NOT work
  await start(async () => sendReminder("hello@example.com"));
  return new Response("ok");
}
```

**Fix:** Pass the imported workflow function directly and provide arguments in the second parameter.

```typescript lineNumbers
import { start } from "workflow/api";
import { sendReminder } from "./workflows/send-reminder";

export async function POST() {
  await start(sendReminder, ["hello@example.com"]); // [!code highlight]
  return new Response("ok");
}
```

## Checklist

Before calling `start()`:

1. Confirm the function includes `"use workflow"` as its first statement.
2. Confirm your framework integration is enabled (for Next.js, wrap `next.config.ts` with [`withWorkflow()`](/docs/api-reference/workflow-next/with-workflow)).
3. Pass the imported workflow function directly to `start()`, not a wrapper callback.
4. Keep the function in a file that goes through Workflow SDK's transform step.

## Related

* [`start()`](/docs/api-reference/workflow-api/start)
* [`withWorkflow()`](/docs/api-reference/workflow-next/with-workflow)
* [Next.js Getting Started](/docs/getting-started/next)


---
title: Step executed multiple times
description: A step ran more than once because its function invocation crashed before it could report a result.
type: troubleshooting
summary: Diagnose duplicate step_started events caused by function timeouts, OOMs, or network issues.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/observability
  - /docs/foundations/errors-and-retries
---

# Step executed multiple times



There may be cases where you see multiple `step_started` events for the same step in a workflow run. This happens if the function invocation executing the step crashes unexpectedly, and the step can not report the error. The step will be re-tried according to your retry policy in this case, but no error will be visible in the [Observability UI](/docs/observability).

## Common Causes

* **Function timeouts**: if your step code runs longer than the configured maximum function duration, it will be killed. Compare the gap between the `step_started` events to your configured function duration to be sure.
* **Out of memory (OOM)**: if your step code loads enough data into memory, especially if the step is invoked concurrently, the function invocation might run out of memory. You can see your function's peak memory use by going to the [Observability Query page](https://vercel.com/docs/observability) and showing the **Function Invocation Peak Memory** metric, then filtering down the **Route** to `/.well-known/workflow` endpoints.
* **Network issues**: persistent firewall, network stability, and related issues might prevent your function from reporting results or errors. This should be temporary.

## Getting Help

If you consistently see multiple `step_started` events and have ruled out function timeouts, OOMs, and firewall issues, please [contact support](https://vercel.com/help).


---
title: step-not-registered
description: A step function is not registered in the current deployment.
type: troubleshooting
summary: Resolve step not registered errors caused by build issues.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/errors/workflow-not-registered
  - /docs/api-reference/workflow-errors/step-not-registered-error
---

# step-not-registered



This error occurs when the Workflow runtime tries to execute a step function that is not registered in the current deployment. When this happens, the step fails (like a `FatalError`) and control is passed back to the workflow function, which can optionally handle the failure.

## Error Message

```
Step "<stepName>" is not registered in the current deployment.
This usually indicates a build or bundling issue that caused the step
to not be included in the deployment.
```

## Why This Happens

Workflow runs are pegged to a specific deployment, so this error is not caused by newer deployments overriding the running code. Instead, it means the step function was not included in the deployment's workflow bundle at build time.

This is an **infrastructure error**, not a user code error.

## Common Causes

### Build tooling issue

Something went wrong during the build process that caused the step function to not be included in the workflow bundle. Check your build logs for errors related to workflow bundling. Common issues include:

* The step file is missing a valid `"use step"` directive
* The step function is not exported from the workflow file
* An esbuild or SWC plugin error silently excluded the step

### Step removed from the codebase

The step function was deleted or its `"use step"` directive was removed, but the workflow still references it. Ensure all steps referenced by your workflow are present in the codebase.

## How to Resolve

1. **Check your build logs:** Look for errors or warnings related to workflow bundling. Ensure the step file contains a valid `"use step"` directive and is properly exported.

2. **Verify the step exists:** Confirm the step function is present in the workflow file and has the `"use step"` directive.

3. **Handle it in your workflow:** Since the step fails like a `FatalError`, you can catch it in your workflow code:

```typescript lineNumbers
declare function processPayment(orderId: string): Promise<any>; // @setup

export async function myWorkflow() {
  "use workflow";

  try {
    const result = await processPayment("order-123");
    return { success: true, result };
  } catch (error) {
    // Step failure (including not registered) is caught here
    console.error("Step failed:", error);
    return { success: false, error: String(error) };
  }
}
```


---
title: timeout-in-workflow
description: Use the sleep function instead of setTimeout or setInterval in workflows.
type: troubleshooting
summary: Resolve the timeout-in-workflow error by replacing setTimeout with the sleep function.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/api-reference/workflow/sleep
---

# timeout-in-workflow



This error occurs when you try to use `setTimeout()`, `setInterval()`, or related timing functions directly inside a workflow function.

## Error Message

```
Timeout functions like "setTimeout" and "setInterval" are not supported in workflow functions. Use the "sleep" function from "workflow" for time-based delays.
```

## Why This Happens

Workflow functions run in a sandboxed environment where timing functions like `setTimeout()` and `setInterval()` are not available. These functions rely on asynchronous scheduling that would break the **deterministic replay** guarantees that workflows depend on.

When a workflow suspends and later resumes, it replays from the event log. If timing functions were allowed, the replay would produce different results than the original execution.

## Quick Fix

Use the `sleep` function from the `workflow` package for time-based delays. Unlike `setTimeout()`, `sleep` is tracked in the event log and replays correctly.

**Before:**

```typescript lineNumbers title="workflows/delayed.ts"
export async function delayedWorkflow() {
  "use workflow";

  // Error - setTimeout is not available in workflow functions
  await new Promise(resolve => setTimeout(resolve, 5000)); // [!code highlight]

  return 'done';
}
```

**After:**

```typescript lineNumbers title="workflows/delayed.ts"
import { sleep } from 'workflow'; // [!code highlight]

export async function delayedWorkflow() {
  "use workflow";

  // sleep is tracked in the event log and replays correctly
  await sleep('5s'); // [!code highlight]

  return 'done';
}
```

## Unavailable Functions

These timing functions cannot be used in workflow functions:

* `setTimeout()`
* `setInterval()`
* `setImmediate()`
* `clearTimeout()`
* `clearInterval()`
* `clearImmediate()`

## Common Scenarios

### Polling with Delays

If you need to poll an external service with delays between requests:

```typescript lineNumbers title="workflows/polling.ts"
import { sleep } from 'workflow';

export async function pollingWorkflow() {
  "use workflow";

  let status = 'pending';

  while (status === 'pending') {
    status = await checkStatus(); // step function
    if (status === 'pending') {
      await sleep('10s'); // [!code highlight]
    }
  }

  return status;
}

async function checkStatus() {
  "use step";
  const response = await fetch('https://api.example.com/status');
  const data = await response.json();
  return data.status;
}
```

### Scheduled Delays

For workflows that need to wait for a specific duration:

```typescript lineNumbers title="workflows/reminder.ts"
import { sleep } from 'workflow';

export async function reminderWorkflow(message: string) {
  "use workflow";

  // Wait 24 hours before sending reminder
  await sleep('24h'); // [!code highlight]

  await sendReminder(message);

  return 'reminder sent';
}

async function sendReminder(message: string) {
  "use step";
  // Send reminder logic
}
```

<Callout type="info">
  The `sleep` function accepts duration strings like `'5s'`, `'10m'`, `'1h'`, `'24h'`, or milliseconds as a number. See the [sleep API reference](/docs/api-reference/workflow/sleep) for more details.
</Callout>


---
title: webhook-invalid-respond-with-value
description: The respondWith option must be "manual" or a Response object.
type: troubleshooting
summary: Resolve the webhook-invalid-respond-with-value error by using a valid respondWith option.
prerequisites:
  - /docs/foundations/hooks
related:
  - /docs/api-reference/workflow/create-webhook
---

# webhook-invalid-respond-with-value



This error occurs when you provide an invalid value for the `respondWith` option when creating a webhook. The `respondWith` option must be either `"manual"` or a `Response` object.

## Error Message

```
Invalid `respondWith` value: [value]
```

## Why This Happens

When creating a webhook with `createWebhook()`, you can specify how the webhook should respond to incoming HTTP requests using the `respondWith` option. This option only accepts specific values:

1. `"manual"` - Allows you to manually send a response from within the workflow
2. A `Response` object - A pre-defined response to send immediately
3. `undefined` (default) - Returns a `202 Accepted` response

## Common Causes

### Using an Invalid String Value

```typescript lineNumbers
// Error - invalid string value
export async function webhookWorkflow() {
  "use workflow";

  const webhook = await createWebhook({
    respondWith: "automatic", // Error! // [!code highlight]
  });
}
```

**Solution:** Use `"manual"` or provide a `Response` object.

```typescript lineNumbers
import { createWebhook } from "workflow";

// Fixed - use "manual"
export async function webhookWorkflow() {
  "use workflow";

  const webhook = await createWebhook({
    respondWith: "manual", // [!code highlight]
  });

  const request = await webhook;

  // Send custom response
  await request.respondWith(new Response("OK", { status: 200 })); // [!code highlight]
}
```

### Using a Non-Response Object

```typescript lineNumbers
// Error - plain object instead of Response
export async function webhookWorkflow() {
  "use workflow";

  const webhook = await createWebhook({
    respondWith: { status: 200, body: "OK" }, // Error! // [!code highlight]
  });
}
```

**Solution:** Create a proper `Response` object.

```typescript lineNumbers
import { createWebhook } from "workflow";

// Fixed - use Response constructor
export async function webhookWorkflow() {
  "use workflow";

  const webhook = await createWebhook({
    respondWith: new Response("OK", { status: 200 }), // [!code highlight]
  });
}
```

## Valid Usage Examples

### Default Behavior (202 Response)

```typescript lineNumbers
import { createWebhook } from "workflow";

// Returns 202 Accepted automatically
const webhook = await createWebhook();
const request = await webhook;
// No need to send a response
```

### Manual Response

```typescript lineNumbers
import { createWebhook } from "workflow";

// Manual response control
const webhook = await createWebhook({
  respondWith: "manual",
});

const request = await webhook;

// Process the request...
const data = await request.json();

// Send custom response
await request.respondWith(
  new Response(JSON.stringify({ success: true }), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  })
);
```

### Pre-defined Response

```typescript lineNumbers
import { createWebhook } from "workflow";

// Immediate response
const webhook = await createWebhook({
  respondWith: new Response("Request received", { status: 200 }),
});

const request = await webhook;
// Response already sent
```

## Learn More

* [createWebhook() API Reference](/docs/api-reference/workflow/create-webhook)
* [resumeWebhook() API Reference](/docs/api-reference/workflow-api/resume-webhook)
* [Webhooks Guide](/docs/foundations/hooks)


---
title: webhook-response-not-sent
description: Manual webhooks must send a response before execution completes.
type: troubleshooting
summary: Resolve the webhook-response-not-sent error by ensuring all code paths call respondWith.
prerequisites:
  - /docs/foundations/hooks
related:
  - /docs/api-reference/workflow/create-webhook
---

# webhook-response-not-sent



This error occurs when a webhook is configured with `respondWith: "manual"` but the workflow does not send a response using `request.respondWith()` before the webhook execution completes.

## Error Message

```
Workflow run did not send a response
```

## Why This Happens

When you create a webhook with `respondWith: "manual"`, you are responsible for calling `request.respondWith()` to send the HTTP response back to the caller. If the workflow execution completes without sending a response, this error will be thrown.

The webhook infrastructure waits for a response to be sent, and if none is provided, it cannot complete the HTTP request properly.

## Common Causes

### Forgetting to Call `request.respondWith()`

```typescript lineNumbers
// Error - no response sent
export async function webhookWorkflow() {
  "use workflow";

  const webhook = await createWebhook({
    respondWith: "manual",
  });

  const request = await webhook;
  const data = await request.json();

  // Process data...
  console.log(data);

  // Error: workflow ends without calling request.respondWith() // [!code highlight]
}
```

**Solution:** Always call `request.respondWith()` when using manual response mode.

```typescript lineNumbers
import { createWebhook } from "workflow";

// Fixed - response sent
export async function webhookWorkflow() {
  "use workflow";

  const webhook = await createWebhook({
    respondWith: "manual",
  });

  const request = await webhook;
  const data = await request.json();

  // Process data...
  console.log(data);

  // Send response before workflow ends // [!code highlight]
  await request.respondWith(new Response("Processed", { status: 200 })); // [!code highlight]
}
```

### Conditional Response Logic

```typescript lineNumbers
// Error - response only sent in some branches
export async function webhookWorkflow() {
  "use workflow";

  const webhook = await createWebhook({
    respondWith: "manual",
  });

  const request = await webhook;
  const data = await request.json();

  if (data.isValid) {
    await request.respondWith(new Response("OK", { status: 200 }));
  }
  // Error: no response when data.isValid is false // [!code highlight]
}
```

**Solution:** Ensure all code paths send a response.

```typescript lineNumbers
import { createWebhook } from "workflow";

// Fixed - response sent in all branches
export async function webhookWorkflow() {
  "use workflow";

  const webhook = await createWebhook({
    respondWith: "manual",
  });

  const request = await webhook;
  const data = await request.json();

  if (data.isValid) { // [!code highlight]
    await request.respondWith(new Response("OK", { status: 200 })); // [!code highlight]
  } else { // [!code highlight]
    await request.respondWith(new Response("Invalid data", { status: 400 })); // [!code highlight]
  } // [!code highlight]
}
```

### Exception Before Response

```typescript lineNumbers
// Error - exception thrown before response
export async function webhookWorkflow() {
  "use workflow";

  const webhook = await createWebhook({
    respondWith: "manual",
  });

  const request = await webhook;

  // Error occurs here // [!code highlight]
  throw new Error("Something went wrong"); // [!code highlight]

  // Never reached
  await request.respondWith(new Response("OK", { status: 200 }));
}
```

**Solution:** Use try-catch to handle errors and send appropriate responses.

```typescript lineNumbers
import { createWebhook } from "workflow";

// Fixed - error handling with response
export async function webhookWorkflow() {
  "use workflow";

  const webhook = await createWebhook({
    respondWith: "manual",
  });

  const request = await webhook;

  try { // [!code highlight]
    // Process request...
    const result = await processData(request); // [!code highlight]
    await request.respondWith(new Response("OK", { status: 200 })); // [!code highlight]
  } catch (error) { // [!code highlight]
    // Send error response // [!code highlight]
    await request.respondWith( // [!code highlight]
      new Response("Internal error", { status: 500 }) // [!code highlight]
    ); // [!code highlight]
  } // [!code highlight]
}
```

## Alternative: Use Default Response Mode

If you don't need custom response control, consider using the default response mode which automatically returns a `202 Accepted` response:

```typescript lineNumbers
import { createWebhook } from "workflow";

// Automatic 202 response - no manual response needed
export async function webhookWorkflow() {
  "use workflow";

  const webhook = await createWebhook(); // [!code highlight]
  const request = await webhook;

  // Process request asynchronously
  await processData(request);

  // No need to call request.respondWith()
}
```

## Learn More

* [createWebhook() API Reference](/docs/api-reference/workflow/create-webhook)
* [resumeWebhook() API Reference](/docs/api-reference/workflow-api/resume-webhook)
* [Webhooks Guide](/docs/foundations/hooks)


---
title: workflow-not-registered
description: A workflow function is not registered in the current deployment.
type: troubleshooting
summary: Resolve workflow not registered errors caused by deployment targeting or build issues.
prerequisites:
  - /docs/foundations/starting-workflows
related:
  - /docs/errors/step-not-registered
  - /docs/api-reference/workflow-errors/workflow-not-registered-error
---

# workflow-not-registered



This error occurs when the Workflow runtime tries to execute a workflow function that is not registered in the current deployment. When this happens, the run fails with a `RUNTIME_ERROR` error code.

## Error Message

```
Workflow "<workflowName>" is not registered in the current deployment.
This usually means a run was started against a deployment that does not
have this workflow, or there was a build/bundling issue.
```

## Why This Happens

This error means the deployment that received the workflow execution request does not have the specified workflow function in its bundle. This is an **infrastructure error**, not a user code error.

## Common Causes

### Run started against a deployment without the workflow

A run was started (or restarted from the dashboard UI) targeting a deployment where the workflow was renamed, moved to a different file, or removed entirely.

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers title="workflows/order.ts (original)"
export async function processOrder(orderId: string) {
  "use workflow";
  // workflow logic
}
```

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers title="workflows/order.ts (current deployment)"
// Renamed from processOrder to handleOrder
export async function handleOrder(orderId: string) { // [!code highlight]
  "use workflow";
  // workflow logic
}
```

If a new run is started targeting the current deployment using the old name `processOrder`, the runtime will not find it.

### Build tooling issue

Something went wrong during the build process that caused the workflow function to not be included in the workflow bundle. Check your build logs for errors related to workflow bundling. Common issues include:

* The workflow file is missing a valid `"use workflow"` directive
* The workflow function is not exported from the workflow file
* An esbuild or SWC plugin error silently excluded the workflow

## How to Resolve

1. **If the workflow was renamed or moved:** Deploy with the workflow restored to its original name and location, then retry the run. Alternatively, start a new run using the updated workflow name against the current deployment.

2. **If it's a build issue:** Check your build logs for errors related to workflow bundling. Ensure the workflow file contains a valid `"use workflow"` directive and is properly exported.


---
title: Errors & Retrying
description: Customize retry behavior with FatalError and RetryableError for robust error handling.
type: conceptual
summary: Control how steps handle failures and customize retry behavior.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/api-reference/workflow/fatal-error
  - /docs/api-reference/workflow/retryable-error
---

# Errors & Retrying



By default, errors thrown inside steps are retried. Additionally, Workflow SDK provides two new types of errors you can use to customize retries.

## Default Retrying

By default, steps retry up to 3 times on arbitrary errors. You can customize the number of retries by adding a `maxRetries` property to the step function.

```typescript lineNumbers
async function callApi(endpoint: string) {
  "use step";

  const response = await fetch(endpoint);

  if (response.status >= 500) {
    // Any uncaught error gets retried
    throw new Error("Uncaught exceptions get retried!"); // [!code highlight]
  }

  return response.json();
}

callApi.maxRetries = 5; // Retry up to 5 times on failure (6 total attempts)
```

Steps get enqueued immediately after a failure. Read on to see how this can be customized.

<Callout type="info">
  When a retried step performs external side effects (payments, emails, API
  writes), ensure those calls are <strong>idempotent</strong> to avoid duplicate
  side effects. See <a href="/docs/foundations/idempotency">Idempotency</a> for
  more information.
</Callout>

## Intentional Errors

When your step needs to intentionally throw an error and skip retrying, simply throw a [`FatalError`](/docs/api-reference/workflow/fatal-error).

```typescript lineNumbers
import { FatalError } from "workflow";

async function callApi(endpoint: string) {
  "use step";

  const response = await fetch(endpoint);

  if (response.status >= 500) {
    // Any uncaught error gets retried
    throw new Error("Uncaught exceptions get retried!");
  }

  if (response.status === 404) {
    throw new FatalError("Resource not found. Skipping retries."); // [!code highlight]
  }

  return response.json();
}
```

## Customize Retry Behavior

When you need to customize the delay on a retry, use [`RetryableError`](/docs/api-reference/workflow/retryable-error) and set the `retryAfter` property.

```typescript lineNumbers
import { FatalError, RetryableError } from "workflow";

async function callApi(endpoint: string) {
  "use step";

  const response = await fetch(endpoint);

  if (response.status >= 500) {
    throw new Error("Uncaught exceptions get retried!");
  }

  if (response.status === 404) {
    throw new FatalError("Resource not found. Skipping retries.");
  }

  if (response.status === 429) {
    throw new RetryableError("Rate limited. Retrying...", { // [!code highlight]
      retryAfter: "1m", // Duration string // [!code highlight]
    }); // [!code highlight]
  }

  return response.json();
}
```

## Advanced Example

This final example combines everything we've learned, along with [`getStepMetadata`](/docs/api-reference/workflow/get-step-metadata).

```typescript lineNumbers
import { FatalError, RetryableError, getStepMetadata } from "workflow";

async function callApi(endpoint: string) {
  "use step";

  const metadata = getStepMetadata();

  const response = await fetch(endpoint);

  if (response.status >= 500) {
    // Exponential backoffs
    throw new RetryableError("Backing off...", {
      retryAfter: (metadata.attempt ** 2) * 1000,  // [!code highlight]
    });
  }

  if (response.status === 404) {
    throw new FatalError("Resource not found. Skipping retries.");
  }

  if (response.status === 429) {
    throw new RetryableError("Rate limited. Retrying...", {
      retryAfter: new Date(Date.now() + 60000),  // Date instance // [!code highlight]
    });
  }

  return response.json();
}
callApi.maxRetries = 5; // Retry up to 5 times on failure (6 total attempts)
```

<Callout type="info">
  Setting <code>maxRetries = 0</code> means the step will run once but will not
  be retried on failure. The default is <code>maxRetries = 3</code>, meaning the
  step can run up to 4 times total (1 initial attempt + 3 retries).
</Callout>

## Error Codes

When a workflow run fails, the error includes an `errorCode` that classifies the failure, alongside the original thrown value (preserved as `cause`):

```typescript lineNumbers
import { WorkflowRunFailedError } from "@workflow/errors";
import { start } from "workflow/api";

const run = await start(myWorkflow, [input]);

try {
  const result = await run.returnValue;
} catch (err) {
  if (WorkflowRunFailedError.is(err)) {
    console.log(err.errorCode); // "USER_ERROR", "RUNTIME_ERROR", or undefined
    // `cause` is the original thrown value, hydrated through the workflow
    // serialization pipeline. It can be any thrown value, so check shape.
    if (err.cause instanceof Error) {
      console.log(err.cause.message); // The error message
    }
  }
}
```

| Code            | Meaning                                                                                                                                                     |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `USER_ERROR`    | An error thrown in your workflow or step code (including propagated step failures like `FatalError`)                                                        |
| `RUNTIME_ERROR` | An internal runtime error such as a corrupted event log or missing data. If you see this, please [file an issue](https://github.com/vercel/workflow/issues) |

<Callout type="info">
  The error code is also available on the run entity via the CLI (`npx workflow inspect runs <runId>`) in the `error.code` field, and as an OTEL span attribute (`workflow.error.code`) for observability.
</Callout>

## Rolling Back Failed Steps

When a workflow fails partway through, it can leave the system in an inconsistent state.
A common pattern to address this is "rollbacks": for each successful step, record a corresponding rollback action that can undo it.
If a later step fails, run the rollbacks in reverse order to roll back.

Key guidelines:

* Make rollbacks steps as well, so they are durable and benefit from retries.
* Ensure rollbacks are [idempotent](/docs/foundations/idempotency); they may run more than once.
* Only enqueue a compensation after its forward step succeeds.

```typescript lineNumbers
// Forward steps
async function reserveInventory(orderId: string) {
  "use step";
  // ... call inventory service to reserve ...
}

async function chargePayment(orderId: string) {
  "use step";
  // ... charge the customer ...
}

// Rollback steps
async function releaseInventory(orderId: string) {
  "use step";
  // ... undo inventory reservation ...
}

async function refundPayment(orderId: string) {
  "use step";
  // ... refund the charge ...
}

export async function placeOrderSaga(orderId: string) {
  "use workflow";

  const rollbacks: Array<() => Promise<void>> = [];

  try {
    await reserveInventory(orderId);
    rollbacks.push(() => releaseInventory(orderId));

    await chargePayment(orderId);
    rollbacks.push(() => refundPayment(orderId));

    // ... more steps & rollbacks ...
  } catch (e) {
    for (const rollback of rollbacks.reverse()) {
      await rollback();
    }
    // Rethrow so the workflow records the failure after rollbacks
    throw e;
  }
}
```


---
title: Hooks & Webhooks
description: Pause workflows and resume them later with external data, user interactions, or HTTP requests.
type: conceptual
summary: Pause workflows and resume them with external data or HTTP requests.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/api-reference/workflow/create-hook
  - /docs/api-reference/workflow/create-webhook
  - /docs/ai/human-in-the-loop
---

# Hooks & Webhooks



Hooks provide a powerful mechanism for pausing workflow execution and resuming it later with external data. They enable workflows to wait for external events, user interactions (also known as "human in the loop"), or HTTP requests. This guide will teach you the core concepts, starting with the low-level Hook primitive and building up to the higher-level Webhook abstraction.

## Understanding Hooks

At their core, **Hooks** are a low-level primitive that allows you to pause a workflow and resume it later with arbitrary [serializable data](/docs/foundations/serialization). Think of them as suspension points in your workflow where you're waiting for external input.

When you create a hook, it generates a unique token that external systems can use to send data back to your workflow. This makes hooks perfect for scenarios like:

* Waiting for approval from a user or admin
* Receiving data from an external system or service
* Implementing event-driven workflows that react to multiple events over time

### Creating Your First Hook

Let's start with a simple example. Here's a workflow that creates a hook and waits for external data:

```typescript lineNumbers
import { createHook } from "workflow";

export async function approvalWorkflow() {
  "use workflow";

  using hook = createHook<{ approved: boolean; comment: string }>();

  console.log("Waiting for approval...");
  console.log("Send approval to token:", hook.token);

  // Workflow pauses here until data is sent
  const result = await hook;

  if (result.approved) {
    console.log("Approved with comment:", result.comment);
  } else {
    console.log("Rejected:", result.comment);
  }
}
```

The workflow will pause at `await hook` until external code sends data to resume it.

<Callout type="info">
  We recommend using the `using` keyword which implements the [TC39 Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management) proposal for automatic cleanup.
</Callout>

<Callout type="info">
  See the full API reference for [`createHook()`](/docs/api-reference/workflow/create-hook) for all available options.
</Callout>

### Resuming a Hook

To send data to a waiting workflow, use [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) from an API route, server action, or any other external context:

```typescript lineNumbers
import { resumeHook } from "workflow/api";

// In an API route or external handler
export async function POST(request: Request) {
  const { token, approved, comment } = await request.json();

  try {
    // Resume the workflow with the approval data
    const result = await resumeHook(token, { approved, comment });
    return Response.json({ success: true, runId: result.runId });
  } catch (error) {
    return Response.json({ error: "Invalid token" }, { status: 404 });
  }
}
```

The key points:

* Hooks allow you to pass **any [serializable data](/docs/foundations/serialization)** as the payload
* You need the hook's `token` to resume it
* The workflow will resume execution right where it left off

### Checking for Token Conflicts

Sometimes you need to know that a hook token has been claimed, but you do not want to wait for external data yet. Await `hook.getConflict()` (available starting in `workflow@4.5.0`) for that:

```typescript lineNumbers
import { createHook } from "workflow";

declare function processOrder(orderId: string): Promise<void>; // @setup

export async function orderWorkflow(orderId: string) {
  "use workflow";

  using hook = createHook({
    token: `order:${orderId}`
  });

  const conflict = await hook.getConflict(); // [!code highlight]
  if (conflict) { // [!code highlight]
    // Another active run already owns this token.
    return { dedupedTo: conflict.runId };
  }

  // The hook token is registered and reserved here.
  await processOrder(orderId);
}
```

Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with `{ runId }` identifying the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). For `hook_conflict` events persisted by older worlds that did not record the owning run's ID, `getConflict()` rejects with `HookConflictError` instead of resolving with an incomplete handle. To act on the owner — inspect its status, wait for its result, or cancel it — pass `conflict.runId` to [`getRun()`](/docs/api-reference/workflow-api/get-run) inside a step. See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies.

### Custom Tokens for Deterministic Hooks

By default, hooks generate a random token. However, you often want to use a **custom token** that external systems can reconstruct. This is especially useful for long-running workflows where the same workflow instance should handle multiple events.

For example, imagine a Slack bot where each channel should have its own workflow instance:

```typescript lineNumbers
import { createHook } from "workflow";

export async function slackChannelBot(channelId: string) {
  "use workflow";

  // Use channel ID in the token so Slack webhooks can find this workflow
  using hook = createHook<SlackMessage>({
    token: `slack_messages:${channelId}`
  });

  for await (const message of hook) {
    console.log(`${message.user}: ${message.text}`);

    if (message.text === "/stop") {
      break;
    }

    await processMessage(message);
  }
}

async function processMessage(message: SlackMessage) {
  "use step";
  // Process the Slack message
}
```

Now your Slack webhook handler can deterministically resume the correct workflow:

```typescript lineNumbers
import { resumeHook } from "workflow/api";

export async function POST(request: Request) {
  const slackEvent = await request.json();
  const channelId = slackEvent.channel;

  try {
    // Reconstruct the token using the channel ID
    await resumeHook(`slack_messages:${channelId}`, slackEvent);

    return new Response("OK");
  } catch (error) {
    return new Response("Hook not found", { status: 404 });
  }
}
```

### Receiving Multiple Events

Hooks are *reusable* - they implement `AsyncIterable`, which means you can use `for await...of` to receive multiple events over time:

```typescript lineNumbers
import { createHook } from "workflow";

export async function dataCollectionWorkflow() {
  "use workflow";

  using hook = createHook<{ value: number; done?: boolean }>();

  const values: number[] = [];

  // Keep receiving data until we get a "done" signal
  for await (const payload of hook) {
    values.push(payload.value);

    if (payload.done) {
      break;
    }
  }

  console.log("Collected values:", values);
  return values;
}
```

Each time you call `resumeHook()` with the same token, the loop receives another value.

### Disposing Hooks Early

When a workflow ends, hooks are automatically disposed. However, you may want to release a hook token early so another workflow can use it while your workflow continues running. Use a block scope with `using` to control when disposal happens:

```typescript lineNumbers
import { createHook } from "workflow";

export async function handoffWorkflow(channelId: string) {
  "use workflow";

  {
    using hook = createHook<{ message: string; handoff?: boolean }>({
      token: `channel:${channelId}`
    });

    for await (const payload of hook) {
      console.log("Received:", payload.message);

      if (payload.handoff) {
        break;
      }
    }
  } // Hook token released here

  // Token is now available for another workflow
  console.log("Continuing with other work...");
}
```

You can also manually dispose using the `dispose()` method:

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
const hook = createHook<{ message: string }>({ token: `channel:${channelId}` });
const payload = await hook;
hook.dispose(); // Manually release the token
```

<Callout type="info">
  After disposal, the hook will no longer receive events and the async iterator will stop yielding values.
</Callout>

## Understanding Webhooks

While hooks are powerful, they require you to manually handle HTTP requests and route them to workflows. **Webhooks** solve this by providing a higher-level abstraction built on top of hooks that:

1. Automatically serializes the entire HTTP [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) object
2. Provides an automatically addressable `url` property pointing to the generated webhook endpoint
3. Handles sending HTTP [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) objects back to the caller

When using Workflow SDK, webhooks are automatically wired up at `/.well-known/workflow/v1/webhook/:token` without any additional setup.

<Callout type="warn">
  `createWebhook()` exposes a public route at `/.well-known/workflow/v1/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests. This is convenient for prototypes and a simple developer experience because you can share the webhook URL (endpoint) without creating another route, but if you need stronger security, prefer [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the request before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) to avoid unauthenticated workflow resumptions.
</Callout>

<Callout type="info">
  See the full API reference for [`createWebhook()`](/docs/api-reference/workflow/create-webhook) for all available options.
</Callout>

### Creating Your First Webhook

Here's a simple webhook that receives HTTP requests. Like hooks, webhooks support the `using` keyword for automatic cleanup:

```typescript lineNumbers
import { createWebhook } from "workflow";

export async function webhookWorkflow() {
  "use workflow";

  using webhook = createWebhook();

  // The webhook is automatically available at this URL
  console.log("Send HTTP requests to:", webhook.url);
  // Example: https://your-app.com/.well-known/workflow/v1/webhook/lJHkuMdQ2FxSFTbUMU84k

  // Workflow pauses until an HTTP request is received
  const request = await webhook;

  console.log("Received request:", request.method, request.url);

  const data = await request.json();
  console.log("Data:", data);
}
```

The webhook will automatically respond with a `202 Accepted` status by default. External systems can simply make an HTTP request to the `webhook.url` to resume your workflow.

### Sending Custom Responses

Webhooks provide two ways to send custom HTTP responses: **static responses** and **dynamic responses**.

#### Static Responses

Use the `respondWith` option to provide a static response that will be sent automatically for every request:

```typescript lineNumbers
import { createWebhook } from "workflow";

export async function webhookWithStaticResponse() {
  "use workflow";

  using webhook = createWebhook({
    respondWith: Response.json({
      success: true, message: "Webhook received"
    }),
  });

  const request = await webhook;

  // The response was already sent automatically
  const data = await request.json();
  await processData(data);
}

async function processData(data: any) {
  "use step";
  // Long-running processing here
}
```

#### Dynamic Responses (Manual Mode)

For dynamic responses based on the request content, set `respondWith: "manual"` and call the `respondWith()` method on the request:

```typescript lineNumbers
import { createWebhook, type RequestWithResponse } from "workflow";

async function sendCustomResponse(request: RequestWithResponse, message: string) {
  "use step";

  // Call respondWith() to send the response
  await request.respondWith(
    new Response(
      JSON.stringify({ message }),
      {
        status: 200,
        headers: { "Content-Type": "application/json" }
      }
    )
  );
}

export async function webhookWithDynamicResponse() {
  "use workflow";

  // Set respondWith to "manual" to handle responses yourself
  using webhook = createWebhook({ respondWith: "manual" });

  const request = await webhook;
  const data = await request.json();

  // Decide what response to send based on the data
  if (data.type === "urgent") {
    await sendCustomResponse(request, "Processing urgently");
  } else {
    await sendCustomResponse(request, "Processing normally");
  }

  // Continue workflow...
}
```

<Callout type="warning">
  When using `respondWith: "manual"`, the `respondWith()` method **must** be called from within a step function due to serialization requirements. This requirement may be removed in the future.
</Callout>

### Handling Multiple Webhook Requests

Like hooks, webhooks support iteration:

```typescript lineNumbers
import { createWebhook, type RequestWithResponse } from "workflow";

async function sendAck(request: RequestWithResponse, message: string) {
  "use step";

  await request.respondWith(
    Response.json({ received: true, message })
  );
}

async function processEvent(data: any) {
  "use step";
  console.log("Processing event:", data);
}

export async function eventCollectorWorkflow() {
  "use workflow";

  using webhook = createWebhook({ respondWith: "manual" });
  console.log("Send events to:", webhook.url);

  for await (const request of webhook) {
    const data = await request.json();

    if (data.type === "done") {
      await sendAck(request, "Workflow complete");
      break;
    }

    await sendAck(request, "Event received");
    await processEvent(data);
  }
}
```

## Hooks vs. Webhooks: When to Use Each

| Feature               | Hooks                                                          | Webhooks                                                                                    |
| --------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| **Data Format**       | Arbitrary serializable data                                    | HTTP `Request` objects                                                                      |
| **URL**               | No automatic URL                                               | Automatic `webhook.url` property                                                            |
| **Response Handling** | N/A                                                            | Can send HTTP `Response` (static or dynamic)                                                |
| **Use Case**          | Custom integrations, type-safe payloads                        | HTTP webhooks, standard REST APIs                                                           |
| **Resuming**          | [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) | Automatic via HTTP, or [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) |

**Use Hooks when:**

* You need full control over the payload structure
* You're integrating with custom event sources
* You want strong TypeScript typing with [`defineHook()`](/docs/api-reference/workflow/define-hook)

**Use Webhooks when:**

* You're receiving HTTP requests from external services
* You need to send HTTP responses back to the caller
* You want automatic URL routing without writing API handlers

## Advanced Patterns

### Type-Safe Hooks with `defineHook()`

The [`defineHook()`](/docs/api-reference/workflow/define-hook) helper provides type safety and runtime validation between creating and resuming hooks using [Standard Schema v1](https://standardschema.dev). Use any compliant validator like Zod or Valibot:

```typescript lineNumbers
import { defineHook } from "workflow";
import { z } from "zod";

// Define the hook with schema for type safety and runtime validation
const approvalHook = defineHook({ // [!code highlight]
  schema: z.object({ // [!code highlight]
    requestId: z.string(), // [!code highlight]
    approved: z.boolean(), // [!code highlight]
    approvedBy: z.string(), // [!code highlight]
    comment: z.string().transform((value) => value.trim()), // [!code highlight]
  }), // [!code highlight]
}); // [!code highlight]

// In your workflow
export async function documentApprovalWorkflow(documentId: string) {
  "use workflow";

  using hook = approvalHook.create({
    token: `approval:${documentId}`
  });

  // Payload is type-safe and validated
  const approval = await hook;

  console.log(`Document ${approval.requestId} ${approval.approved ? "approved" : "rejected"}`);
  console.log(`By: ${approval.approvedBy}, Comment: ${approval.comment}`);
}

// In your API route - both type-safe and runtime-validated!
export async function POST(request: Request) {
  const { documentId, ...approvalData } = await request.json();

  try {
    // The schema validates the payload before resuming the workflow
    await approvalHook.resume(`approval:${documentId}`, approvalData);
    return new Response("OK");
  } catch (error) {
    return Response.json({ error: "Invalid token or validation failed" }, { status: 400 });
  }
}
```

This pattern is especially valuable in larger applications where the workflow and API code are in separate files, providing both compile-time type safety and runtime validation.

## Best Practices

### Token Design

Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always use randomly generated tokens to prevent unauthorized access to public webhook endpoints.

When using custom tokens with `createHook()`:

* **Make them deterministic**: Base them on data the external system can reconstruct (like channel IDs, user IDs, etc.)
* **Use namespacing**: Prefix tokens to avoid conflicts (e.g., `slack:${channelId}`, `github:${repoId}`)
* **Include routing information**: Ensure the token contains enough information to identify the correct workflow instance

### Response Handling in Webhooks

* Use **static responses** (`respondWith: Response`) for simple acknowledgments
* Use **manual mode** (`respondWith: "manual"`) when responses depend on request processing
* Remember that `respondWith()` must be called from within a step function

### Iterating Over Events

Both hooks and webhooks support iteration, making them perfect for long-running event loops:

{/* @skip-typecheck: incomplete code sample */}

```typescript
using hook = createHook<Event>();

for await (const event of hook) {
  await processEvent(event);

  if (shouldStop(event)) {
    break;
  }
}
```

This pattern allows a single workflow instance to handle multiple events over time, maintaining state between events.

## Related Documentation

* [Serialization](/docs/foundations/serialization) - Understanding what data can be passed through hooks
* [`createHook()` API Reference](/docs/api-reference/workflow/create-hook)
* [`createWebhook()` API Reference](/docs/api-reference/workflow/create-webhook)
* [`defineHook()` API Reference](/docs/api-reference/workflow/define-hook)
* [`resumeHook()` API Reference](/docs/api-reference/workflow-api/resume-hook)
* [`resumeWebhook()` API Reference](/docs/api-reference/workflow-api/resume-webhook)


---
title: Idempotency
description: Make step retries safe and coordinate duplicate workflow starts with hook tokens.
type: conceptual
summary: Use step IDs for retry-safe external calls, and route duplicate workflow-start requests through deterministic hook tokens.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/foundations/errors-and-retries
  - /docs/foundations/starting-workflows
  - /docs/foundations/hooks
---

# Idempotency



Idempotency is a property of an operation that ensures repeated attempts have the same effect as a single attempt.

In Workflow, idempotency shows up in two related places: step idempotency makes external calls safe when a step retries, and run idempotency coordinates duplicate requests that try to start the same workflow.

## Step Idempotency

In distributed systems (calling external APIs), it is not always possible to ensure an operation has only been performed once just by seeing if it succeeds.
Consider a payment API that charges the user $10, but due to network failures, the confirmation response is lost. When the step retries (because the previous attempt was considered a failure), it will charge the user again.

To prevent this, many external APIs support idempotency keys. An idempotency key is a unique identifier for an operation that can be used to deduplicate requests.

Every step invocation has a stable `stepId` that stays the same across retries.
Use it as the idempotency key when calling third-party APIs.

```typescript lineNumbers
import { getStepMetadata } from "workflow";

async function chargeUser(userId: string, amount: number) {
  "use step";

  const { stepId } = getStepMetadata(); // [!code highlight]

  // Example: Stripe-style idempotency key
  // This guarantees only one charge is created even if the step retries
  await stripe.charges.create(
    {
      amount,
      currency: "usd",
      customer: userId,
    },
    {
      idempotencyKey: stepId, // [!code highlight]
    }
  );
}
```

Why this works:

* **Stable across retries**: `stepId` does not change between attempts.
* **Globally unique per step**: Fulfills the uniqueness requirement for an idempotency key.

## Run idempotency

Step idempotency protects side effects **inside** a workflow run. Run idempotency answers a different question: if the same API request is sent twice, should it create one workflow run or two?

Because [hooks](/docs/foundations/hooks) already ensure globally unique active tokens, Workflow can use the same mechanism to coordinate duplicate requests while a run is active.

Use a hook token as the idempotency key for an active workflow run. Hook tokens are globally unique while they are active: if another run tries to create a hook with the same token, the runtime records a conflict, `hook.getConflict()` resolves with `{ runId }` identifying the run that owns the token, and the hook rejects with [`HookConflictError`](/docs/errors/hook-conflict) when the workflow awaits or iterates its payload.

The token should come from your domain, such as an order ID, invoice ID, import ID, or request ID. Create the hook near the beginning of the workflow and check `await hook.getConflict()` before doing duplicate-sensitive work that depends on owning the active token. Calling `createHook()` alone does not register the hook — awaiting `getConflict()` suspends the workflow to commit the registration.

```typescript lineNumbers
import { createHook } from "workflow";

type OrderRequest = { confirmed: boolean };
type OrderResult =
  | { status: "processed" | "cancelled" }
  | { status: "duplicate"; runId: string };
declare function chargeOrder(orderId: string): Promise<void>; // @setup

export async function processOrder(orderId: string): Promise<OrderResult> {
  "use workflow";

  using request = createHook<OrderRequest>({ // [!code highlight]
    token: `order:${orderId}`, // [!code highlight]
  }); // [!code highlight]

  const conflict = await request.getConflict(); // [!code highlight]
  if (conflict) { // [!code highlight]
    // Another active run already owns this order's token. // [!code highlight]
    return { status: "duplicate" as const, runId: conflict.runId }; // [!code highlight]
  } // [!code highlight]

  const { confirmed } = await request;

  if (!confirmed) {
    return { status: "cancelled" as const };
  }

  await chargeOrder(orderId);
  return { status: "processed" as const };
}
```

The runtime creates the hook atomically. At most one active hook can own `order:${orderId}`, so duplicate workflow runs converge on one active owner. A duplicate run observes `getConflict()` resolving with `{ runId }` and returns before it reaches `chargeOrder()`. To act on the owner, pass `conflict.runId` to [`getRun()`](/docs/api-reference/workflow-api/get-run) inside a step — the duplicate run can do more than report the owner; see [conflict-handling strategies](#conflict-handling-strategies) below.

Outside the workflow, try to resume the hook first. If the hook is not registered yet, start the workflow and retry the resume until the new run creates the hook:

```typescript lineNumbers
import { resumeHook, start } from "workflow/api";
import { HookNotFoundError } from "workflow/errors";
import { processOrder } from "./workflows/process-order";

type OrderRequest = { confirmed: boolean };

async function resumeOrder(token: string, payload: OrderRequest) {
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      return await resumeHook(token, payload); // [!code highlight]
    } catch (error) {
      if (!HookNotFoundError.is(error)) throw error;
      await new Promise((resolve) => setTimeout(resolve, 100));
    }
  }

  throw new Error("Order workflow did not register its hook in time");
}

export async function POST(request: Request) {
  const { orderId, confirmed } = await request.json();
  const token = `order:${orderId}`;
  const payload = { confirmed };

  try {
    const hook = await resumeHook(token, payload); // [!code highlight]
    return Response.json({ runId: hook.runId, reused: true });
  } catch (error) {
    if (!HookNotFoundError.is(error)) throw error;
  }

  const run = await start(processOrder, [orderId]); // [!code highlight]
  const resumed = await resumeOrder(token, payload);

  // A concurrent request's run may have won the race between `start()` // [!code highlight]
  // and hook registration. The resume always reaches the actual active // [!code highlight]
  // owner, so compare run IDs instead of waiting for this run to finish. // [!code highlight]
  return Response.json({ // [!code highlight]
    runId: resumed.runId, // [!code highlight]
    reused: resumed.runId !== run.runId, // [!code highlight]
  }); // [!code highlight]
}
```

<Callout type="warn">
  This avoids creating a new run only after the first run has registered its hook. Because `start()` returns before the run body executes and calls `createHook()`, two concurrent requests can both observe "no hook yet" and each call `start()`. The race is resolved inside the workflow body, where the losing run observes `getConflict()` resolving with the active owner and returns without doing duplicate-sensitive work — and the route detects it by comparing the resumed hook's `runId` against the run it just started, without waiting for either run to finish. A native API for atomically starting a run and registering a hook is in the works. Until then, model recovery inside the workflow by checking `hook.getConflict()`.
</Callout>

This is active-run coordination. When the workflow completes and disposes the hook, the token can be used again. If a duplicate request after completion must return the original result instead of starting fresh work, persist that completed result under the same domain key.

### Conflict-handling strategies

Some workflow systems resolve duplicate IDs with a fixed, pre-declared policy — typically a static choice between rejecting the new execution, deferring to the existing one, or terminating it. Workflow has no policy enum. `hook.getConflict()` hands the duplicate run the conflicting run's ID, and the policy is ordinary code — including policies that inspect state before deciding, which static configuration can't express. Retrieve a `Run` handle for the owner with [`getRun()`](/docs/api-reference/workflow-api/get-run) inside a step:

```typescript lineNumbers
import { getRun } from "workflow/api";

async function getOwnerStatus(runId: string) {
  "use step";
  return await getRun(runId).status;
}

async function getOwnerResult(runId: string) {
  "use step";
  return await getRun(runId).returnValue;
}

async function cancelOwner(runId: string) {
  "use step";
  await getRun(runId).cancel();
}
```

The example above implements **reject the duplicate**: return the owner's `runId` and let the caller decide. Other common strategies:

**Adopt the owner's result.** Wait for the active run to finish and return its result, so callers cannot tell which run did the work:

```typescript lineNumbers
import { createHook } from "workflow";

type OrderRequest = { confirmed: boolean };
declare function getOwnerResult(runId: string): Promise<unknown>; // @setup
declare function processOwnedOrder(orderId: string): Promise<{ status: string }>; // @setup

export async function processOrder(orderId: string) {
  "use workflow";

  using request = createHook<OrderRequest>({
    token: `order:${orderId}`,
  });

  const conflict = await request.getConflict();
  if (conflict) {
    // Callers get the same result regardless of which run did the work.
    return await getOwnerResult(conflict.runId); // [!code highlight]
  }

  return await processOwnedOrder(orderId);
}
```

**Inspect the owner before deciding.** Branch on the owner's live state:

```typescript lineNumbers
import { createHook } from "workflow";

type OrderRequest = { confirmed: boolean };
declare function getOwnerStatus(runId: string): Promise<string>; // @setup
declare function processOwnedOrder(orderId: string): Promise<{ status: string }>; // @setup

export async function processOrder(orderId: string) {
  "use workflow";

  using request = createHook<OrderRequest>({
    token: `order:${orderId}`,
  });

  const conflict = await request.getConflict();
  if (conflict) {
    const status = await getOwnerStatus(conflict.runId); // [!code highlight]
    if (status === "running") {
      return { status: "duplicate" as const, runId: conflict.runId };
    }
    // Owner already reached a terminal state; its hook will be released.
  }

  return await processOwnedOrder(orderId);
}
```

**Signal the owner instead of doing the work.** The duplicate run knows the token, so it can deliver this run's input to the owner's hook from a step:

```typescript lineNumbers
import { createHook } from "workflow";
import { resumeHook } from "workflow/api";

type OrderRequest = { confirmed: boolean };

async function forwardToOwner(token: string, payload: OrderRequest) {
  "use step";
  await resumeHook(token, payload); // [!code highlight]
}

export async function processOrder(orderId: string, confirmed: boolean) {
  "use workflow";

  const token = `order:${orderId}`;
  using request = createHook<OrderRequest>({ token });

  const conflict = await request.getConflict();
  if (conflict) {
    await forwardToOwner(token, { confirmed }); // [!code highlight]
    return { status: "forwarded" as const, runId: conflict.runId };
  }

  // ... own the token and do the work
}
```

**Supersede the owner.** Newest-wins: cancel the active run, then claim the released token. Cancellation disposes the owner's hooks; the retry loop covers the window where that disposal has not propagated yet:

```typescript lineNumbers
import { createHook } from "workflow";
import { getRun } from "workflow/api";

type OrderRequest = { confirmed: boolean };
declare function chargeOrder(orderId: string): Promise<void>; // @setup

async function cancelOwner(runId: string) {
  "use step";
  await getRun(runId).cancel();
}

export async function processOrderNewestWins(orderId: string) {
  "use workflow";

  const token = `order:${orderId}`;

  for (let attempt = 0; attempt < 3; attempt++) {
    using request = createHook<OrderRequest>({ token });

    const conflict = await request.getConflict();
    if (!conflict) {
      // Token claimed — this run is now the owner.
      const { confirmed } = await request;
      if (confirmed) {
        await chargeOrder(orderId);
      }
      return { status: "processed" as const };
    }

    await cancelOwner(conflict.runId); // [!code highlight]
  }

  throw new Error(`Could not claim ${token} after cancelling the owner`);
}
```

If duplicate requests should only reuse the active run without sending data, use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) as an advisory pre-check before calling `start()`. The workflow should still check `hook.getConflict()`, because the lookup and `start()` are not atomic.

Because this pattern uses hooks for idempotency, duplicate requests can also inject additional data and steer the existing run. The route example above uses `resumeHook()` for that: if the hook already exists, the duplicate request resumes the active workflow; if the hook is not registered yet, the route starts the workflow and retries `resumeHook()` so the payload is not dropped.

## Related docs

* Learn about retries in [Errors & Retrying](/docs/foundations/errors-and-retries)
* API reference: [`getStepMetadata`](/docs/api-reference/workflow/get-step-metadata)
* API reference: [`createHook()`](/docs/api-reference/workflow/create-hook)
* API reference: [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token)
* API reference: [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook)
* API reference: [`start()`](/docs/api-reference/workflow-api/start)
* Learn about deterministic hook tokens in [Hooks](/docs/foundations/hooks)


---
title: Foundations
description: Learn the core concepts of workflow programming to build durable, long-running applications.
type: overview
summary: Explore the core concepts you need to use workflows effectively.
related:
  - /docs/foundations/workflows-and-steps
  - /docs/getting-started
---

# Foundations



Workflow programming can be a slight shift from how you traditionally write real-world applications. Learning the foundations now will go a long way toward helping you use workflows effectively.

<Cards>
<Card href="/docs/foundations/workflows-and-steps" title="Workflows and Steps">Build long-running, stateful application logic that persists progress and resumes after failures.</Card>
<Card href="/docs/foundations/starting-workflows" title="Starting Workflows">Trigger workflow execution with the start() function and track progress with Run objects.</Card>
<Card href="/docs/foundations/errors-and-retries" title="Errors & Retrying">Customize retry behavior with FatalError and RetryableError for robust error handling.</Card>
<Card href="/docs/foundations/hooks" title="Hooks & Webhooks">Pause workflows and resume them later with external data, user interactions, or HTTP requests.</Card>
<Card href="/docs/foundations/streaming" title="Streaming">Stream data in real-time to clients for progress updates and incremental content delivery.</Card>
<Card href="/docs/foundations/serialization" title="Serialization">Understand how workflow data is serialized and persisted across suspensions and resumptions.</Card>
<Card href="/docs/foundations/idempotency" title="Idempotency">Make step retries safe and coordinate duplicate workflow starts with hook tokens.</Card>
<Card href="/docs/foundations/versioning" title="Versioning">Understand how workflow runs are pinned to deployments, how to recover runs after a fix, and how to opt in to newer code explicitly.</Card>
</Cards>


---
title: Serialization
description: Understand how workflow data is serialized and persisted across suspensions and resumptions.
type: conceptual
summary: Learn which types can be passed between workflow and step functions.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/errors/serialization-failed
---

# Serialization



All function arguments and return values passed between workflow and step functions must be serializable. Workflow SDK uses a custom serialization system built on top of [devalue](https://github.com/sveltejs/devalue). This system supports standard JSON types, as well as a few additional popular Web API types.

<Callout type="info">
  The serialization system ensures that all data persists correctly across workflow suspensions and resumptions, enabling durable execution.
</Callout>

## Supported Serializable Types

The following types can be serialized and passed through workflow functions:

**Standard JSON Types:**

* `string`
* `number`
* `boolean`
* `null`
* Arrays of serializable values
* Objects with string keys and serializable values

**Extended Types:**

* `undefined`
* `bigint`
* `ArrayBuffer`
* `BigInt64Array`, `BigUint64Array`
* `Date`
* `Float32Array`, `Float64Array`
* `Int8Array`, `Int16Array`, `Int32Array`
* `Map<Serializable, Serializable>`
* `RegExp`
* `Set<Serializable>`
* `URL`
* `URLSearchParams`
* `Uint8Array`, `Uint8ClampedArray`, `Uint16Array`, `Uint32Array`

**Notable:**

<Callout type="info">
  These types have special handling and are explained in detail in the sections below.
</Callout>

* `Headers`
* `Request`
* `Response`
* `ReadableStream<Serializable>`
* `WritableStream<Serializable>`

## Pass-by-Value Semantics

**Parameters are passed by value, not by reference.** Steps receive deserialized copies of data. Mutations inside a step won't affect the original in the workflow.

**Incorrect:**

```typescript title="workflows/incorrect-mutation.ts" lineNumbers
export async function updateUserWorkflow(userId: string) {
  "use workflow";

  let user = { id: userId, name: "John", email: "john@example.com" };
  await updateUserStep(user);

  // user.email is still "john@example.com" // [!code highlight]
  console.log(user.email); // [!code highlight]
}

async function updateUserStep(user: { id: string; name: string; email: string }) {
  "use step";
  user.email = "newemail@example.com"; // Changes are lost // [!code highlight]
}
```

**Correct - return the modified data:**

```typescript title="workflows/correct-mutation.ts" lineNumbers
export async function updateUserWorkflow(userId: string) {
  "use workflow";

  let user = { id: userId, name: "John", email: "john@example.com" };
  user = await updateUserStep(user); // Reassign the return value // [!code highlight]

  console.log(user.email); // "newemail@example.com"
}

async function updateUserStep(user: { id: string; name: string; email: string }) {
  "use step";
  user.email = "newemail@example.com";
  return user; // [!code highlight]
}
```

**Custom Classes:**

* Class instances that implement [`WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE`](#custom-class-serialization)

## Streaming

`ReadableStream` and `WritableStream` are supported as serializable types with special handling. These streams can be passed between workflow and step functions while maintaining their streaming capabilities.

For complete information about using streams in workflows, including patterns for AI streaming, file processing, and progress updates, see the [Streaming Guide](/docs/foundations/streaming).

## Request & Response

The Web API [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) APIs are supported by the serialization system,
and can be passed around between workflow and step functions similarly to other data types.

As a convenience, these two APIs are treated slightly differently when used
within a workflow function: calling the `text()` / `json()` / `arrayBuffer()` instance
methods is automatically treated as a step function invocation. This allows you to consume
the body directly in the workflow context while maintaining proper serialization and caching.

For example, consider how receiving a webhook request provides the entire `Request`
instance into the workflow context. You may consume the body of that request directly
in the workflow, which will be cached as a step result for future resumptions of the workflow:

```typescript title="workflows/webhook.ts" lineNumbers
import { createWebhook } from "workflow";

export async function handleWebhookWorkflow() {
  "use workflow";

  const webhook = createWebhook();
  const request = await webhook;

  // The body of the request will only be consumed once // [!code highlight]
  const body = await request.json(); // [!code highlight]

  // …
}
```

### Using `fetch` in Workflows

Because `Request` and `Response` are serializable, Workflow SDK provides a `fetch` function that can be used directly in workflow functions:

```typescript title="workflows/api-call.ts" lineNumbers
import { fetch } from "workflow"; // [!code highlight]

export async function apiWorkflow() {
  "use workflow";

  // fetch can be called directly in workflows // [!code highlight]
  const response = await fetch("https://api.example.com/data"); // [!code highlight]
  const data = await response.json();

  return data;
}
```

The implementation is straightforward - `fetch` from workflow is a step function that wraps the standard `fetch`:

```typescript title="Implementation" lineNumbers
export async function fetch(...args: Parameters<typeof globalThis.fetch>) {
  "use step";
  return globalThis.fetch(...args);
}
```

This allows you to make HTTP requests directly in workflow functions while maintaining deterministic replay behavior through automatic caching.

## Custom Class Serialization

By default, custom class instances cannot be serialized because the serialization system doesn't know how to reconstruct them. You can make your classes serializable by implementing two static methods using special symbols from the `@workflow/serde` package.

### Basic Example

{/* @expect-error:2351 */}

```typescript title="workflows/custom-class.ts" lineNumbers
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; // [!code highlight]

class Point {
  constructor(
    public x: number,
    public y: number
  ) {}

  // Define how to serialize an instance to plain data
  static [WORKFLOW_SERIALIZE](instance: Point) { // [!code highlight]
    return { x: instance.x, y: instance.y }; // [!code highlight]
  } // [!code highlight]

  // Define how to reconstruct an instance from plain data
  static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) { // [!code highlight]
    return new Point(data.x, data.y); // [!code highlight]
  } // [!code highlight]
}
```

Once you've implemented these methods, instances of your class can be passed between workflow and step functions:

{/* @expect-error:2351 */}

```typescript title="workflows/geometry.ts" lineNumbers
import { Point } from "./custom-class";

export async function geometryWorkflow() {
  "use workflow";

  const point = new Point(10, 20);
  // Point is serialized automatically
  const doubled = await doublePoint(point); // [!code highlight]

  console.log(doubled.x, doubled.y); // 20, 40
  return doubled;
}

async function doublePoint(point: Point) {
  "use step";
  // Returns a new Point instance
  return new Point(point.x * 2, point.y * 2); // [!code highlight]
}
```

### How It Works

1. **`WORKFLOW_SERIALIZE`**: A static method that receives a class instance and returns serializable data (primitives, plain objects, arrays, etc.)

2. **`WORKFLOW_DESERIALIZE`**: A static method that receives the serialized data and returns a new class instance

3. **Automatic Registration**: The SWC compiler plugin automatically detects classes that implement these symbols and registers them for serialization. Each class receives a deterministic `classId` derived from its file path and class name, and is registered into the global `Symbol.for("workflow-class-registry")` registry at build time — no manual registration step is required

### Requirements

<Callout type="warn">
  `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` must be implemented as **static** methods on the class. Defining them as instance methods is not supported.
</Callout>

* The data returned by `WORKFLOW_SERIALIZE` must itself be serializable (see [Supported Serializable Types](#supported-serializable-types))
* Both symbols must be implemented together - a class with only one will not be serializable

<Callout type="warn">
  The `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` methods run inside the workflow context and are subject to the same constraints as `"use workflow"` functions. This means:

  * No Node.js-specific APIs (like `fs`, `path`, `crypto`, etc.)
  * No non-deterministic operations (like `Math.random()` or `Date.now()`)
  * No external network calls

  Keep these methods simple and focused on data transformation only.
</Callout>

### Instance Methods as Steps

In practice, many classes have methods that need Node.js APIs, perform network calls, or interact with databases — operations that are not allowed in the `"use workflow"` execution context. You can make these methods workflow-compatible by adding `"use step"` to them. The SWC compiler will strip the method bodies from the workflow bundle and replace them with proxy functions that invoke the method as a step — with full Node.js runtime access. The `this` context (the class instance) is automatically serialized and deserialized across the workflow/step boundary.

This requires the class to implement `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE`, so that the instance can be passed to the step execution context.

{/* @expect-error:2351 */}

```typescript title="workflows/order.ts" lineNumbers
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde"; // [!code highlight]
import { db } from "../lib/db";

class Order {
  constructor(
    public id: string,
    public items: Map<string, number>,
    public createdAt: Date
  ) {}

  // Custom serialization — data must be serializable types
  static [WORKFLOW_SERIALIZE](instance: Order) { // [!code highlight]
    return { // [!code highlight]
      id: instance.id, // [!code highlight]
      items: instance.items, // Map is serializable // [!code highlight]
      createdAt: instance.createdAt, // Date is serializable // [!code highlight]
    }; // [!code highlight]
  } // [!code highlight]

  static [WORKFLOW_DESERIALIZE](data: { // [!code highlight]
    id: string; // [!code highlight]
    items: Map<string, number>; // [!code highlight]
    createdAt: Date; // [!code highlight]
  }) { // [!code highlight]
    return new Order(data.id, data.items, data.createdAt); // [!code highlight]
  } // [!code highlight]

  // Methods without "use step" run in the workflow context
  // and must follow the same constraints as workflow functions
  total(): number {
    let sum = 0;
    for (const quantity of this.items.values()) {
      sum += quantity;
    }
    return sum;
  }

  // Instance methods with "use step" run as step functions
  // with full Node.js access — `this` is automatically serialized
  async save(): Promise<void> {
    "use step"; // [!code highlight]
    await db.orders.insert({ // [!code highlight]
      id: this.id, // [!code highlight]
      items: Object.fromEntries(this.items), // [!code highlight]
      createdAt: this.createdAt, // [!code highlight]
    }); // [!code highlight]
  }

  async sendConfirmation(email: string): Promise<string> {
    "use step"; // [!code highlight]
    const res = await fetch("https://api.example.com/email", { // [!code highlight]
      method: "POST", // [!code highlight]
      body: JSON.stringify({ // [!code highlight]
        to: email, // [!code highlight]
        orderId: this.id, // [!code highlight]
        itemCount: this.items.size, // [!code highlight]
      }), // [!code highlight]
    }); // [!code highlight]
    const { messageId } = await res.json();
    return messageId;
  }
}
```

The class can then be used naturally inside a workflow function. Instance methods marked with `"use step"` are each executed as a step — with automatic caching, retry semantics, and full Node.js runtime access. Methods *without* `"use step"` run directly in the workflow context, so they must follow the same constraints as workflow functions:

{/* @expect-error:2693 */}

```typescript title="workflows/process-order.ts" lineNumbers
export async function processOrderWorkflow(
  orderId: string,
  items: Map<string, number>,
  email: string
) {
  "use workflow";

  const order = new Order(orderId, items, new Date()); // [!code highlight]

  // Runs in the workflow context — no "use step" needed
  const itemCount = order.total(); // [!code highlight]

  // Each "use step" instance method call runs as a separate step
  await order.save(); // [!code highlight]
  const messageId = await order.sendConfirmation(email); // [!code highlight]

  return { orderId, itemCount, messageId };
}
```

Note that [pass-by-value semantics](#pass-by-value-semantics) also apply to the `this` context of `"use step"` instance methods. Modifying instance properties inside a step method will not affect the original instance in the workflow. If you need to update instance state, return `this` from the step method and re-assign the variable in the workflow:

{/* @expect-error:2351 */}

```typescript title="workflows/order.ts" lineNumbers
export class Order {
  // ...

  async addItem(name: string, quantity: number): Promise<Order> {
    "use step";
    this.items.set(name, quantity);
    return this; // [!code highlight]
  }
}
```

{/* @expect-error:2693,2552,2304 */}

```typescript title="workflows/process-order.ts" lineNumbers
export async function processOrderWorkflow() {
  "use workflow";

  let order = new Order(orderId, items, new Date());

  // Re-assign to capture the updated instance
  order = await order.addItem("Widget", 3); // [!code highlight]
}
```


---
title: Starting Workflows
description: Trigger workflow execution with the start() function and track progress with Run objects.
type: guide
summary: Trigger workflows and track their execution using the start() function.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/api-reference/workflow-api/start
---

# Starting Workflows



Once you've defined your workflow functions, you need to trigger them to begin execution. This is done using the `start()` function from `workflow/api`, which enqueues a new workflow run and returns a `Run` object that you can use to track its progress.

## The `start()` Function

The [`start()`](/docs/api-reference/workflow-api/start) function is used to programmatically trigger workflow executions from runtime contexts like API routes, Server Actions, or any server-side code.

```typescript lineNumbers
import { start } from "workflow/api";
import { handleUserSignup } from "./workflows/user-signup";

export async function POST(request: Request) {
  const { email } = await request.json();

  // Start the workflow
  const run = await start(handleUserSignup, [email]); // [!code highlight]

  return Response.json({
    message: "Workflow started",
    runId: run.runId
  });
}
```

**Key Points:**

* `start()` returns immediately after enqueuing the workflow - it doesn't wait for completion
* The first argument is your workflow function
* The second argument is an array of arguments to pass to the workflow (optional if the workflow takes no arguments)
* All arguments must be [serializable](/docs/foundations/serialization)

**Learn more**: [`start()` API Reference](/docs/api-reference/workflow-api/start)

## The `Run` Object

When you call `start()`, it returns a [`Run`](/docs/api-reference/workflow-api/start#returns) object that provides access to the workflow's status and results.

```typescript lineNumbers
import { start } from "workflow/api";
import { processOrder } from "./workflows/process-order";

const run = await start(processOrder, [/* orderId */]);

// The run object has properties you can await
console.log("Run ID:", run.runId);

// Check the workflow status
const status = await run.status; // "running" | "completed" | "failed"

// Get the workflow's return value (blocks until completion)
const result = await run.returnValue;
```

**Key Properties:**

* `runId` - Unique identifier for this workflow run
* `status` - Current status of the workflow (async)
* `returnValue` - The value returned by the workflow function (async, blocks until completion)
* `readable` - ReadableStream for streaming updates from the workflow

<Callout type="info">
  Most `Run` properties are async getters that return promises. You need to `await` them to get their values. For a complete list of properties and methods, see the API reference below.
</Callout>

**Learn more**: [`Run` API Reference](/docs/api-reference/workflow-api/start#returns)

## Common Patterns

### Fire and Forget

The most common pattern is to start a workflow and immediately return, letting it execute in the background:

```typescript lineNumbers
import { start } from "workflow/api";
import { sendNotifications } from "./workflows/notifications";

export async function POST(request: Request) {
  // Start workflow and don't wait for it
  const run = await start(sendNotifications, [userId]);

  // Return immediately
  return Response.json({
    message: "Notifications queued",
    runId: run.runId
  });
}
```

### Wait for Completion

If you need to wait for the workflow to complete before responding:

```typescript lineNumbers
import { start } from "workflow/api";
import { generateReport } from "./workflows/reports";

export async function POST(request: Request) {
  const run = await start(generateReport, [reportId]);

  // Wait for the workflow to complete
  const report = await run.returnValue; // [!code highlight]

  return Response.json({ report });
}
```

<Callout type="warn">
  Be cautious when waiting for `returnValue` - if your workflow takes a long time, your API route may timeout.
</Callout>

### Stream Updates to Client

Stream real-time updates from your workflow as it executes, without waiting for completion:

```typescript lineNumbers
import { start } from "workflow/api";
import { generateAIContent } from "./workflows/ai-generation";

export async function POST(request: Request) {
  const { prompt } = await request.json();

  // Start the workflow
  const run = await start(generateAIContent, [prompt]);

  // Get the readable stream (can also use run.readable as shorthand)
  const stream = run.getReadable(); // [!code highlight]

  // Return the stream immediately
  return new Response(stream, {
    headers: {
      "Content-Type": "application/octet-stream",
    },
  });
}
```

Your workflow can obtain a writable stream using [`getWritable()`](/docs/api-reference/workflow/get-writable):

```typescript lineNumbers
import { getWritable } from "workflow";

export async function generateAIContent(prompt: string) {
  "use workflow";

  const writable = getWritable(); // [!code highlight]

  await streamContentToClient(writable, prompt);

  return { status: "complete" };
}

async function streamContentToClient(
  writable: WritableStream,
  prompt: string
) {
  "use step";

  const writer = writable.getWriter();

  // Stream updates as they become available
  for (let i = 0; i < 10; i++) {
    const chunk = new TextEncoder().encode(`Update ${i}\n`);
    await writer.write(chunk);
  }

  writer.releaseLock();
}
```

<Callout type="info">
  Streams are particularly useful for AI workflows where you want to show progress to users in real-time, or for long-running processes that produce intermediate results.
</Callout>

**Learn more**: [Streaming in Workflows](/docs/foundations/serialization#streaming)

### Check Status Later

You can retrieve a workflow run later using its `runId` with [`getRun()`](/docs/api-reference/workflow-api/get-run):

```typescript lineNumbers
import { getRun } from "workflow/api";

export async function GET(request: Request) {
  const url = new URL(request.url);
  const runId = url.searchParams.get("runId");

  // Retrieve the existing run
  const run = getRun(runId); // [!code highlight]

  // Check its status
  const status = await run.status;

  if (status === "completed") {
    const result = await run.returnValue;
    return Response.json({ result });
  }

  return Response.json({ status });
}
```

## Next Steps

Now that you understand how to start workflows and track their execution:

* Browse the [Cookbook](/cookbook) for copy-paste recipes covering composition, scheduling, timeouts, and more
* Explore [Errors & Retrying](/docs/foundations/errors-and-retries) to handle failures gracefully
* Check the [`start()` API Reference](/docs/api-reference/workflow-api/start) for complete details


---
title: Streaming
description: Stream data in real-time to clients for progress updates and incremental content delivery.
type: conceptual
summary: Stream real-time data to clients without waiting for workflow completion.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/api-reference/workflow/get-writable
  - /docs/ai/resumable-streams
---

# Streaming



Workflows can stream data in real-time to clients without waiting for the entire workflow to complete. This enables progress updates, AI-generated content, log messages, and other incremental data to be delivered as workflows execute.

## Getting Started with `getWritable()`

Every workflow run has a default writable stream that steps can write to using [`getWritable()`](/docs/api-reference/workflow/get-writable). Data written to this stream becomes immediately available to clients consuming the workflow's output.

```typescript title="workflows/simple-streaming.ts" lineNumbers
import { getWritable } from "workflow";

async function writeProgress(message: string) {
  "use step";

  // Steps can write to the run's default stream
  const writable = getWritable<string>(); // [!code highlight]
  const writer = writable.getWriter();
  await writer.write(message);
  writer.releaseLock();
}

export async function simpleStreamingWorkflow() {
  "use workflow";

  await writeProgress("Starting task...");
  await writeProgress("Processing data...");
  await writeProgress("Task complete!");
}
```

### Consuming the Stream

Use the `Run` object's `readable` property to consume the stream from your API route:

```typescript title="app/api/stream/route.ts" lineNumbers
import { start } from "workflow/api";
import { simpleStreamingWorkflow } from "./workflows/simple-streaming";

export async function POST() {
  const run = await start(simpleStreamingWorkflow);

  // Return the readable stream to the client
  return new Response(run.readable, {
    headers: { "Content-Type": "text/plain" }
  });
}
```

When a client makes a request to this endpoint, they'll receive each message as it's written, without waiting for the workflow to complete.

### Resuming Streams from a Specific Point

Use `run.getReadable({ startIndex })` to resume a stream from a specific position. This is useful for reconnecting after timeouts or network interruptions:

```typescript title="app/api/resume-stream/[runId]/route.ts" lineNumbers
import { getRun } from "workflow/api";

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

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

  const run = getRun(runId);
  const stream = run.getReadable({ startIndex }); // [!code highlight]

  return new Response(stream, {
    headers: { "Content-Type": "text/plain" }
  });
}
```

This allows clients to reconnect and continue receiving data from where they left off, rather than restarting from the beginning.

`startIndex` also supports **negative values** to read relative to the end of the stream. For example, `startIndex: -5` starts 5 chunks before the current end. This is useful when you want to show the most recent output without reading the entire stream history.

On an active (not-yet-closed) stream, the negative index resolves relative to the chunk count at connection time; any chunks written afterward are still delivered normally.

{/* @skip-typecheck: incomplete code sample */}

```typescript
// Read only the last 10 chunks
const stream = run.getReadable({ startIndex: -10 });
```

If the absolute value exceeds the total number of chunks, reading starts from the beginning (the value is clamped to 0).

<Callout type="warn">
  Because streams are live and continue receiving chunks, negative `startIndex` values resolve to different absolute positions on each call. Accurate pagination over a live stream requires cursor-based access, which is not yet supported. Keep this in mind when building clients that paginate over stream data.
</Callout>

## Streams as Data Types

[`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) and [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream) are standard Web Streams API types that Workflow SDK makes serializable. These are not custom types - they follow the web standard - but Workflow SDK adds the ability to pass them between functions while maintaining their streaming capabilities.

Unlike regular values that are fully serialized to the [event log](/docs/how-it-works/event-sourcing), streams maintain their streaming capabilities when passed between functions.

**Key properties:**

* Stream references can be passed between workflow and step functions
* Stream data flows directly without being stored in the event log
* Streams preserve their state across workflow suspension points

<Callout type="info">
  **How Streams Persist Across Workflow Suspensions**

  Streams in Workflow SDK are backed by persistent, resumable storage provided by the "world" implementation. This is what enables streams to maintain their state even when workflows suspend and resume:

  * **Vercel deployments**: Streams are backed by a performant Redis-based stream
  * **Local development**: Stream chunks are stored in the filesystem
</Callout>

### Passing Streams as Arguments

Since streams are serializable data types, you don't need to use the special [`getWritable()`](/docs/api-reference/workflow/get-writable). You can even wire your own streams through workflows, passing them as arguments from outside into steps.

Here's an example of passing a request body stream through a workflow to a step that processes it:

```typescript title="app/api/upload/route.ts" lineNumbers
import { start } from "workflow/api";
import { streamProcessingWorkflow } from "./workflows/streaming";

export async function POST(request: Request) {
  // Streams can be passed as workflow arguments
  const run = await start(streamProcessingWorkflow, [request.body]); // [!code highlight]
  await run.returnValue;

  return Response.json({ status: "complete" });
}
```

```typescript title="workflows/streaming.ts" lineNumbers
export async function streamProcessingWorkflow(
  inputStream: ReadableStream<Uint8Array> // [!code highlight]
) {
  "use workflow";

  // Workflow passes stream to step for processing
  const result = await processInputStream(inputStream); // [!code highlight]
  return { length: result.length };
}

async function processInputStream(input: ReadableStream<Uint8Array>) {
  "use step";

  // Step reads from the stream
  const chunks: Uint8Array[] = [];

  for await (const chunk of input) {
    chunks.push(chunk);
  }

  return Buffer.concat(chunks).toString("utf8");
}
```

## Important Limitation

<Callout type="info">
  **Streams Cannot Be Used Directly in Workflow Context**

  You cannot read from or write to streams directly within a workflow function. All stream operations must happen in step functions.
</Callout>

Workflow functions must be deterministic to support replay. Since streams bypass the [event log](/docs/how-it-works/event-sourcing) for performance, reading stream data in a workflow would break determinism - each replay could see different data. By requiring all stream operations to happen in steps, the framework ensures consistent behavior.

For more on determinism and replay, see [Workflows and Steps](/docs/foundations/workflows-and-steps).

```typescript title="workflows/bad-example.ts" lineNumbers
import { getWritable } from "workflow";

export async function badWorkflow() {
  "use workflow";

  const writable = getWritable<string>();

  // Cannot read/write streams in workflow context
  const writer = writable.getWriter(); // [!code highlight]
  await writer.write("data"); // [!code highlight]
}
```

```typescript title="workflows/good-example.ts" lineNumbers
import { getWritable } from "workflow";

export async function goodWorkflow() {
  "use workflow";

  // Delegate stream operations to steps
  await writeToStream("data");
}

async function writeToStream(data: string) {
  "use step";

  // Stream operations must happen in steps
  const writable = getWritable<string>();
  const writer = writable.getWriter();
  await writer.write(data);
  writer.releaseLock();
}
```

## Namespaced Streams

Use `getWritable({ namespace: 'name' })` to create multiple independent streams for different types of data. This is useful when you want to separate logs, metrics, data outputs, or other distinct channels.

```typescript title="workflows/multi-stream.ts" lineNumbers
import { getWritable } from "workflow";

type LogEntry = { level: string; message: string };
type MetricEntry = { cpu: number; memory: number };

async function writeLogs() {
  "use step";

  const logs = getWritable<LogEntry>({ namespace: "logs" }); // [!code highlight]
  const writer = logs.getWriter();

  await writer.write({ level: "info", message: "Task started" });
  await writer.write({ level: "info", message: "Processing..." });

  writer.releaseLock();
}

async function writeMetrics() {
  "use step";

  const metrics = getWritable<MetricEntry>({ namespace: "metrics" }); // [!code highlight]
  const writer = metrics.getWriter();

  await writer.write({ cpu: 45, memory: 512 });
  await writer.write({ cpu: 52, memory: 520 });

  writer.releaseLock();
}

async function closeStreams() {
  "use step";

  await getWritable({ namespace: "logs" }).close();
  await getWritable({ namespace: "metrics" }).close();
}

export async function multiStreamWorkflow() {
  "use workflow";

  await writeLogs();
  await writeMetrics();
  await closeStreams();
}
```

### Consuming Namespaced Streams

Use `run.getReadable({ namespace: 'name' })` to access specific streams:

```typescript title="app/api/multi-stream/route.ts" lineNumbers
import { start } from "workflow/api";
import { multiStreamWorkflow } from "./workflows/multi";

type LogEntry = { level: string; message: string };
type MetricEntry = { cpu: number; memory: number };

export async function POST(request: Request) {
  const run = await start(multiStreamWorkflow);

  // Access specific named streams // [!code highlight]
  const logs = run.getReadable<LogEntry>({ namespace: "logs" }); // [!code highlight]
  const metrics = run.getReadable<MetricEntry>({ namespace: "metrics" }); // [!code highlight]

  // Return the logs stream to the client
  return new Response(logs, {
    headers: { "Content-Type": "application/json" }
  });
}
```

## Common Patterns

### Progress Updates for Long-Running Tasks

Send incremental progress updates to keep users informed during lengthy workflows:

```typescript title="workflows/batch-processing.ts" lineNumbers
import { getWritable, sleep } from "workflow";

type ProgressUpdate = {
  item: string;
  progress: number;
  status: string;
};

async function processItem(
  item: string,
  current: number,
  total: number
) {
  "use step";

  const writable = getWritable<ProgressUpdate>(); // [!code highlight]
  const writer = writable.getWriter();

  // Simulate processing
  await new Promise(resolve => setTimeout(resolve, 1000));

  // Send progress update // [!code highlight]
  await writer.write({ // [!code highlight]
    item, // [!code highlight]
    progress: Math.round((current / total) * 100), // [!code highlight]
    status: "processing" // [!code highlight]
  }); // [!code highlight]

  writer.releaseLock();
}

async function finalizeProgress() {
  "use step";

  await getWritable().close();
}

export async function batchProcessingWorkflow(items: string[]) {
  "use workflow";

  for (let i = 0; i < items.length; i++) {
    await processItem(items[i], i + 1, items.length);
    await sleep("1s");
  }

  await finalizeProgress();
}
```

### Streaming AI Responses with `WorkflowAgent`

Stream AI-generated content using AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) from `@ai-sdk/workflow`. The agent writes `ModelCallStreamPart` chunks to the workflow stream, and route handlers convert them to UI message chunks with `createModelCallToUIChunkTransform()` before returning the response:

```typescript title="workflows/ai-assistant.ts" lineNumbers
import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow";
import { tool } from "ai";
import { getWritable } from "workflow";
import { z } from "zod";

async function searchFlights({ query }: { query: string }) {
  "use step";

  // ... search logic ...
  return { flights: [/* results */] };
}

export async function aiAssistantWorkflow(userMessage: string) {
  "use workflow";

  const agent = new WorkflowAgent({
    model: "anthropic/claude-haiku-4.5",
    instructions: "You are a helpful flight assistant.",
    tools: {
      searchFlights: tool({
        description: "Search for flights",
        inputSchema: z.object({ query: z.string() }),
        execute: searchFlights,
      }),
    },
  });

  // LLM response will be streamed to the run's writable
  await agent.stream({
    messages: [{ role: "user", content: userMessage }],
    writable: getWritable<ModelCallStreamPart>(), // [!code highlight]
  });
}
```

```typescript title="app/api/ai-assistant/route.ts" lineNumbers
import { createModelCallToUIChunkTransform } from "@ai-sdk/workflow";
import { createUIMessageStreamResponse } from "ai";
import { start } from "workflow/api";
import { aiAssistantWorkflow } from "./workflows/ai";

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

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

  return createUIMessageStreamResponse({
    stream: run.readable.pipeThrough(createModelCallToUIChunkTransform()), // [!code highlight]
  });
}
```

<Callout type="info">
  For the full agent API and migration notes, see the [`WorkflowAgent` documentation](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent).
</Callout>

### Streaming Between Steps

One step produces a stream and another step consumes it:

```typescript title="workflows/stream-pipeline.ts" lineNumbers
export async function streamPipelineWorkflow() {
  "use workflow";

  // Streams can be passed between steps
  const stream = await generateData(); // [!code highlight]
  const results = await consumeData(stream); // [!code highlight]

  return { count: results.length };
}

async function generateData(): Promise<ReadableStream<number>> {
  "use step";

  // Producer step creates a stream
  return new ReadableStream<number>({
    start(controller) {
      for (let i = 0; i < 10; i++) {
        controller.enqueue(i);
      }
      controller.close();
    }
  });
}

async function consumeData(readable: ReadableStream<number>) {
  "use step";

  // Consumer step reads from the stream
  const values: number[] = [];
  for await (const value of readable) {
    values.push(value);
  }
  return values;
}
```

### Processing Large Files Without Memory Overhead

Process large files by streaming chunks through transformation steps:

```typescript title="workflows/file-processing.ts" lineNumbers
export async function fileProcessingWorkflow(fileUrl: string) {
  "use workflow";

  // Chain streams through multiple processing steps
  const rawStream = await downloadFile(fileUrl); // [!code highlight]
  const processedStream = await transformData(rawStream); // [!code highlight]
  await uploadResult(processedStream); // [!code highlight]
}

async function downloadFile(url: string): Promise<ReadableStream<Uint8Array>> {
  "use step";
  const response = await fetch(url);
  return response.body!;
}

async function transformData(input: ReadableStream<Uint8Array>): Promise<ReadableStream<Uint8Array>> {
  "use step";

  // Transform stream chunks without loading entire file into memory
  return input.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
    transform(chunk, controller) {
      // Process each chunk individually
      controller.enqueue(chunk);
    }
  }));
}

async function uploadResult(stream: ReadableStream<Uint8Array>) {
  "use step";
  await fetch("https://storage.example.com/upload", {
    method: "POST",
    body: stream,
  });
}
```

## Best Practices

**Release locks properly:**

```typescript lineNumbers
const writer = writable.getWriter();
try {
  await writer.write(data);
} finally {
  writer.releaseLock(); // Always release
}
```

<Callout type="info">
  Stream locks acquired in a step only apply within that step, not across other steps. This enables multiple writers to write to the same stream concurrently.
</Callout>

<Callout type="warn">
  If a lock is not released, the step function's HTTP request cannot terminate. Even though the step returns and the workflow continues, the underlying request will remain active until it times out—wasting compute resources unnecessarily.
</Callout>

**Close streams when done:**

```typescript lineNumbers
import { getWritable } from "workflow";

async function finalizeStream() {
  "use step";

  await getWritable().close(); // Signal completion
}
```

Streams are automatically closed when the workflow run completes, but explicitly closing them signals completion to consumers earlier.

**Use typed streams for type safety:**

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
const writable = getWritable<MyDataType>();
const writer = writable.getWriter();
await writer.write({ /* typed data */ });
```

## Stream Failures

When a step returns a stream, the step is considered successful once it returns, even if the stream later encounters an error. The workflow won't automatically retry the step. The consumer of the stream must handle errors gracefully. For more on retry behavior, see [Errors and Retries](/docs/foundations/errors-and-retries).

```typescript title="workflows/stream-error-handling.ts" lineNumbers
import { FatalError } from "workflow";

async function produceStream(): Promise<ReadableStream<number>> {
  "use step";

  // Step succeeds once it returns the stream
  return new ReadableStream<number>({
    start(controller) {
      controller.enqueue(1);
      controller.enqueue(2);
      // Error occurs after step has completed // [!code highlight]
      controller.error(new Error("Stream failed")); // [!code highlight]
    }
  });
}

async function consumeStream(stream: ReadableStream<number>) {
  "use step";

  try { // [!code highlight]
    for await (const value of stream) {
      console.log(value);
    }
  } catch (error) { // [!code highlight]
    // Retrying won't help since the stream is already errored // [!code highlight]
    throw new FatalError("Stream failed"); // [!code highlight]
  } // [!code highlight]
}

export async function streamErrorWorkflow() {
  "use workflow";

  const stream = await produceStream(); // Step succeeds // [!code highlight]
  await consumeStream(stream); // Consumer handles errors // [!code highlight]
}
```

<Callout type="info">
  Stream errors don't trigger automatic retries for the producer step. Design your stream consumers to handle errors appropriately. Since the stream is already in an errored state, retrying the consumer won't help - use `FatalError` to fail the workflow immediately.
</Callout>

## Related Documentation

* [`getWritable()` API Reference](/docs/api-reference/workflow/get-writable) - Get the workflow's writable stream
* [`sleep()` API Reference](/docs/api-reference/workflow/sleep) - Pause workflow execution for a duration
* [`start()` API Reference](/docs/api-reference/workflow-api/start) - Start workflows and access the `Run` object
* [`getRun()` API Reference](/docs/api-reference/workflow-api/get-run) - Retrieve runs and their streams later
* [world.streams](/docs/api-reference/workflow-runtime/world/streams) - Low-level stream read/write/close via World SDK
* [WorkflowAgent](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) - AI agents with durable, resumable streaming support
* [Errors and Retries](/docs/foundations/errors-and-retries) - Understanding error handling and retry behavior
* [Serialization](/docs/foundations/serialization) - Understanding what data types can be passed in workflows
* [Workflows and Steps](/docs/foundations/workflows-and-steps) - Core concepts of workflow execution


---
title: Versioning
description: Understand how workflow runs are pinned to deployments, how to recover runs after a fix, and how to opt in to newer code explicitly.
type: guide
summary: Keep in-flight runs stable by default, then choose explicit upgrade boundaries when you need them.
prerequisites:
  - /docs/foundations/starting-workflows
related:
  - /docs/api-reference/workflow-api/start
  - /cookbook/common-patterns/workflow-composition
---

# Versioning



Workflow runs are pinned to the deployment that starts them. When a run begins, Workflow SDK records the deployment for that run and continues executing the run on that same copy of your code.

That default is intentional. Durable workflows can pause for minutes, days, or months. If the code underneath a paused run changed every time you deployed, an in-flight run could resume into a different function body, different step names, or different input types than the ones it started with. That can make type safety fragile and can break long-running work in hard-to-debug ways.

With Workflow SDK, you can keep shipping. New runs use new deployments, while existing runs keep the version they already understand.

## Default behavior

Start a workflow normally:

```typescript title="app/api/orders/route.ts" lineNumbers
import { start } from "workflow/api";
import { fulfillOrder } from "@/workflows/fulfill-order";

export async function POST(request: Request) {
  const { orderId } = await request.json();

  const run = await start(fulfillOrder, [orderId]); // [!code highlight]

  return Response.json({ runId: run.runId });
}
```

The run is tied to the deployment that handled this request. If you deploy a new version while the workflow is [sleeping](/docs/api-reference/workflow/sleep), [waiting on a hook](/docs/foundations/hooks), [retrying a step](/docs/foundations/errors-and-retries), or processing later queue messages, that existing run still resumes on the original deployment.

```typescript title="workflows/fulfill-order.ts" lineNumbers
import { sleep } from "workflow";

export async function fulfillOrder(orderId: string) {
  "use workflow";

  await reserveInventory(orderId);
  await sleep("2d");
  await chargeCustomer(orderId);
  await shipOrder(orderId);
}

async function reserveInventory(orderId: string) {
  "use step";
  // ...
}

async function chargeCustomer(orderId: string) {
  "use step";
  // ...
}

async function shipOrder(orderId: string) {
  "use step";
  // ...
}
```

If you deploy a change to `chargeCustomer()` while a run is in the two-day sleep, the existing run does not suddenly resume into the new implementation. It continues on the deployment it started on. The next order starts on the latest deployment and uses the new code from the beginning.

## Fixing in-flight runs

Sometimes you deploy because the old code had a bug. The safest fix is usually explicit:

1. Deploy the fixed code.
2. Find the affected runs in [observability](/docs/observability) or with the CLI.
3. Cancel the old runs if they are still running.
4. Rerun them on the latest deployment with the same inputs.

This keeps the version boundary visible. The old run ends as cancelled or failed, and the replacement run starts fresh on the fixed deployment. This is a good fit for one-off, ad-hoc upgrades where you explicitly opt in to moving affected runs onto a new version.

```bash
# Inspect affected runs and copy the exact workflowName value.
npx workflow inspect runs \
  --backend vercel \
  --status running

# Cancel one run.
npx workflow cancel <run-id> \
  --backend vercel

# Or bulk-cancel matching running runs.
npx workflow cancel \
  --status running \
  --workflowName "workflow//./workflows/fulfill-order//fulfillOrder" \
  --backend vercel
```

The `--workflowName` filter expects the generated workflow ID, not only the exported function's short name. Use the `workflowName` value from `workflow inspect runs`, and use [`parseWorkflowName()`](/docs/api-reference/workflow-observability/parse-workflow-name) when you need display-friendly names.

In the [observability UI](/docs/observability), use **Rerun on latest** to enqueue the workflow again with the same inputs against the latest deployment.

If you are writing your own recovery route, call `start()` with the same arguments and `deploymentId: "latest"`:

```typescript title="app/api/orders/rerun/route.ts" lineNumbers
import { start } from "workflow/api";
import { fulfillOrder } from "@/workflows/fulfill-order";

export async function POST(request: Request) {
  const { orderId } = await request.json();

  const run = await start(fulfillOrder, [orderId], {
    deploymentId: "latest", // [!code highlight]
  });

  return Response.json({ runId: run.runId });
}
```

<Callout type="warn">
  `deploymentId: "latest"` is currently a Vercel-specific feature. Other Worlds may implement this option differently to match their own deployment runtimes, and the World spec may rename it from `deploymentId` to `version` in a future SDK version. On Vercel, `"latest"` resolves to the most recent deployment matching your current environment. Because the caller and target deployment can be different, keep the [workflow function name and file path](/docs/errors/workflow-not-registered), arguments, and return value backward-compatible across the deployments you plan to bridge.
</Callout>

## Self upgrading workflows

Some workflows are expected to run for a very long time. Scheduled loops, recurring jobs, agents, and chat sessions often should not stay on one deployment forever.

Model those as a sequence of runs. Each run does a bounded piece of work, then starts the next run on the latest deployment and exits. This is similar to `continueAsNew` in other durable execution systems, but in Workflow SDK it is just [explicit recursion through `start()`](/cookbook/common-patterns/workflow-composition).

```typescript title="workflows/daily-digest.ts" lineNumbers
import { sleep } from "workflow";
import { start } from "workflow/api";

type DigestState = {
  userId: string;
  lastSentAt?: string;
};

export async function dailyDigest(state: DigestState) {
  "use workflow";

  const sentAt = await sendDigest(state.userId);
  await sleep("1d");

  const nextRunId = await continueDigest({
    ...state,
    lastSentAt: sentAt,
  });

  return { continuedAs: nextRunId };
}

async function continueDigest(state: DigestState) {
  "use step";

  const run = await start(dailyDigest, [state], {
    deploymentId: "latest", // [!code highlight]
  });

  return run.runId;
}

async function sendDigest(userId: string) {
  "use step";
  // ...
  return new Date().toISOString();
}
```

This pattern gives every run a clear lifecycle:

* The current run stays on its original deployment.
* The next run starts on the latest deployment.
* The [serialized `state`](/docs/foundations/serialization) is the migration boundary between versions.
* Observability can link parent and child runs when a workflow starts another run.

## Carrying context forward

Anything that is [serializable by Workflow SDK](/docs/foundations/serialization) can be passed from one run to the next as an argument. That includes plain state objects, `ReadableStream`, `WritableStream`, and other supported serialized values.

For example, a long export can register its [output stream](/docs/foundations/streaming) once, write progress from each run, and pass the same stream plus updated state into the next run:

```typescript title="workflows/export-report.ts" lineNumbers
import { getWritable } from "workflow";
import { start } from "workflow/api";

type ExportState = {
  exportId: string;
  page: number;
};

export async function exportReport(
  state: ExportState,
  progress?: WritableStream<string>
) {
  "use workflow";

  // Register the stream once. Continuation runs receive this same stream
  // as an argument and keep writing to it.
  const stream =
    progress !== undefined ? progress : getWritable<string>();

  const hasMore = await exportPage(state, stream);

  if (!hasMore) {
    await writeProgress(stream, { type: "done", totalPages: state.page });
    return { totalPages: state.page };
  }

  const nextRunId = await continueExportOnLatest(
    { ...state, page: state.page + 1 },
    stream
  );

  return { continuedAs: nextRunId };
}

async function continueExportOnLatest(
  state: ExportState,
  stream: WritableStream<string>
) {
  "use step";

  const run = await start(exportReport, [state, stream], {
    deploymentId: "latest", // [!code highlight]
  });

  return run.runId;
}

async function exportPage(
  state: ExportState,
  stream: WritableStream<string>
) {
  "use step";

  // Do work for this version boundary.
  const hasMore = state.page < 10;
  const writer = stream.getWriter();

  try {
    await writer.write(
      JSON.stringify({ type: "page", page: state.page }) + "\n"
    );
    return hasMore;
  } finally {
    writer.releaseLock();
  }
}

async function writeProgress(
  stream: WritableStream<string>,
  event: { type: "done"; totalPages: number }
) {
  "use step";

  const writer = stream.getWriter();
  try {
    await writer.write(JSON.stringify(event) + "\n");
  } finally {
    writer.releaseLock();
  }
}
```

```typescript title="app/api/export/route.ts" lineNumbers
import { start } from "workflow/api";
import { exportReport } from "@/workflows/export-report";

export async function POST(request: Request) {
  const { exportId } = await request.json();

  const run = await start(exportReport, [{ exportId, page: 1 }]);

  // Linked continuation runs keep writing to the stream registered by
  // the parent run, because that stream is passed forward as an argument.
  return new Response(run.readable, {
    headers: { "Content-Type": "application/jsonl" },
  });
}
```

Each run still has one clear version boundary: the current run stays on its original deployment, the next run starts on the latest deployment, and only the explicit state and stream handle are carried forward.


---
title: Workflows and Steps
description: Build long-running, stateful application logic that persists progress and resumes after failures.
type: conceptual
summary: Understand the two function types that make up a workflow.
prerequisites:
  - /docs/foundations
related:
  - /docs/how-it-works/understanding-directives
  - /docs/foundations/serialization
---

# Workflows and Steps



import { File, Folder, Files } from "fumadocs-ui/components/files";

Workflows (a.k.a. *durable functions*) are a programming model for building long-running, stateful application logic that can maintain its execution state across restarts, failures, or user events. Unlike traditional serverless functions that lose all state when they terminate, workflows persist their progress and can resume exactly where they left off.

Moreover, workflows let you easily model complex multi-step processes in simple, elegant code. To do this, we introduce two fundamental entities:

1. **Workflow Functions**: Functions that orchestrate/organize steps
2. **Step Functions**: Functions that carry out the actual work

## Workflow Functions

*Directive: `"use workflow"`*

Workflow functions define the entrypoint of a workflow and organize how step functions are called. This type of function does not have access to the Node.js runtime, and usable `npm` packages are limited.

Although this may seem limiting initially, this feature is important in order to suspend and accurately resume execution of workflows.

It helps to think of the workflow function less like a full JavaScript runtime and more like "stitching together" various steps using conditionals, loops, try/catch handlers, `Promise.all`, and other language primitives.

```typescript lineNumbers
export async function processOrderWorkflow(orderId: string) {
  "use workflow"; // [!code highlight]

  // Orchestrate multiple steps
  const order = await fetchOrder(orderId);
  const payment = await chargePayment(order);

  return { orderId, status: "completed" };
}
```

**Key Characteristics:**

* Runs in a sandboxed environment without full Node.js access (see [Workflow Globals](/docs/api-reference/workflow-globals) for what's available)
* All step results are persisted to the [event log](/docs/how-it-works/event-sourcing)
* Must be **deterministic** to allow resuming after failures

Determinism in the workflow is required to resume the workflow from a suspension. Essentially, the workflow code gets re-run multiple times during its lifecycle, each time using the [event log](/docs/how-it-works/event-sourcing) to resume the workflow to the correct spot.

The sandboxed environment that workflows run in already ensures determinism. For instance, `Math.random` and `Date` constructors are fixed in workflow runs, so you are safe to use them, and the framework ensures that the values don't change across replays.

## Step Functions

*Directive: `"use step"`*

Step functions perform the actual work in a workflow and have full runtime access.

```typescript lineNumbers
async function chargePayment(order: Order) {
  "use step"; // [!code highlight]

  // Full Node.js access - use any npm package
  const stripe = new Stripe(process.env.STRIPE_KEY);

  const charge = await stripe.charges.create({
    amount: order.total,
    currency: "usd",
    source: order.paymentToken
  });

  return { chargeId: charge.id };
}
```

**Key Characteristics:**

* Full Node.js runtime and npm package access
* Automatic retry on errors
* Results persisted for replay

By default, steps have a maximum of 3 retry attempts before they fail and propagate the error to the workflow. Learn more about errors and retrying in the [Errors & Retrying](/docs/foundations/errors-and-retries) page.

<Callout type="warning">
  **Important:** Due to serialization, parameters are passed by **value, not by reference**. If you pass an object or array to a step and mutate it, those changes will **not** be visible in the workflow context. Always return modified data from your step functions instead. See [Pass-by-Value Semantics](/docs/foundations/serialization#pass-by-value-semantics) for details and examples.
</Callout>

<Callout type="info">
  Step functions are primarily meant to be used inside a workflow.
</Callout>

Calling a step from outside a workflow or from another step will essentially run the step in the same process like a normal function (in other words, the `use step` directive is a no-op). This means you can reuse step functions in other parts of your codebase without needing to duplicate business logic.

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
async function updateUser(userId: string) {
  "use step";
  await db.insert(...);
}

// Used inside a workflow
export async function userOnboardingWorkflow(userId: string) {
  "use workflow";
  await updateUser(userId);
  // ... more steps
}

// Used directly outside a workflow
export async function POST() {
  await updateUser("123");
  // ... more logic
}
```

<Callout type="info">
  Keep in mind that calling a step function outside of a workflow function will not have retry semantics, nor will it be observable. Additionally, certain workflow-specific functions like [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) will throw an error when used inside a step that's called outside a workflow.
</Callout>

### Suspension and Resumption

Workflow functions have the ability to automatically suspend while they wait on asynchronous work. While suspended, the workflow's state is stored via the [event log](/docs/how-it-works/event-sourcing) and no compute resources are used until the workflow resumes execution.

<FluidComputeCallout />

There are multiple ways a workflow can suspend:

* Waiting on a step function: the workflow yields while the step runs in the step runtime.
* Using `sleep()` to pause for some fixed duration.
* Awaiting on a promise returned by [`createWebhook()`](/docs/api-reference/workflow/create-webhook), which resumes the workflow when an external system passes data into the workflow.

```typescript lineNumbers
import { sleep, createWebhook } from "workflow";

export async function documentReviewProcess(userId: string) {
  "use workflow";

  await sleep("30d"); // Sleep will suspend without consuming any resources [!code highlight]

  // Create a webhook for external workflow resumption
  const webhook = createWebhook();

  // Send the webhook url to some external service or in an email, etc.
  await sendHumanApprovalEmail("Click this link to accept the review", webhook.url)

  const data = await webhook; // The workflow suspends till the URL is resumed [!code highlight]

  console.log("Document reviewed!")
}
```

## Writing Workflows

### Basic Structure

The simplest workflow consists of a workflow function and one or more step functions.

```typescript lineNumbers
// Workflow function (orchestrates the steps)
export async function greetingWorkflow(name: string) {
  "use workflow";

  const message = await greet(name);
  return { message };
}

// Step function (does the actual work)
async function greet(name: string) {
  "use step";

  // Access Node.js APIs
  const message = `Hello ${name} at ${new Date().toISOString()}`;
  console.log(message);
  return message;
}
```

### Project structure

While you can organize workflow and step functions however you like, we find that larger projects benefit from some structure:

<Files>
  <Folder name="workflows" defaultOpen disabled>
    <Folder name="userOnboarding" defaultOpen disabled>
      <File name="index.ts" />

      <File name="steps.ts" />
    </Folder>

    <Folder name="aiVideoGeneration" defaultOpen disabled>
      <File name="index.ts" />

      <Folder name="steps" defaultOpen disabled>
        <File name="transcribeUpload.ts" />

        <File name="generateVideo.ts" />

        <File name="notifyUser.ts" />
      </Folder>
    </Folder>

    <Folder name="shared" defaultOpen disabled>
      <File name="validateInput.ts" />

      <File name="logActivity.ts" />
    </Folder>
  </Folder>
</Files>

You can choose to organize your steps into a single `steps.ts` file or separate files within a `steps` folder. The `shared` folder is a good place to put common steps that are used by multiple workflows.

<Callout type="info">
  Splitting up steps and workflows will also help avoid most bundler related bugs with the Workflow SDK.
</Callout>


---
title: Astro
description: Set up your first durable workflow in an Astro application.
type: guide
summary: Set up Workflow SDK in an Astro app.
prerequisites:
  - /docs/getting-started
related:
  - /docs/foundations/workflows-and-steps
---

# Astro





This guide will walk through setting up your first workflow in an Astro app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects.

***

<Steps>
  <Step>
    ## Create Your Astro Project

    Start by creating a new Astro project. This command will create a new directory named `my-workflow-app` and setup a minimal Astro project inside it.

    ```bash
    npm create astro@latest my-workflow-app -- --template minimal --install --yes
    ```

    Enter the newly made directory:

    ```bash
    cd my-workflow-app
    ```

    ### Install `workflow`

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add workflow
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    ### Configure Astro

    Add `workflow()` to your Astro config. This enables usage of the `"use workflow"` and `"use step"` directives.

    ```typescript title="astro.config.mjs" lineNumbers
    // @ts-check
    import { defineConfig } from "astro/config";
    import { workflow } from "workflow/astro";

    // https://astro.build/config
    export default defineConfig({
      integrations: [workflow()],
    });
    ```

    `workflow()` accepts an options object:

    | Option      | Type                                                      | Default                           | Description                                                                                                                                                                                                                                                                                                                                                                  |
    | ----------- | --------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles — helps stay under the Vercel 250MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. |

    <Accordion type="single" collapsible>
      <AccordionItem value="typescript-intellisense" className="[&_h3]:my-0">
        <AccordionTrigger className="text-sm">
          ### Setup IntelliSense for TypeScript (Optional)
        </AccordionTrigger>

        <AccordionContent className="[&_p]:my-2">
          To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`:

          ```json title="tsconfig.json" lineNumbers
          {
            "compilerOptions": {
              // ... rest of your TypeScript config
              "plugins": [
                {
                  "name": "workflow" // [!code highlight]
                }
              ]
            }
          }
          ```
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  </Step>

  <Step>
    ## Create Your First Workflow

    Create a new file for our first workflow:

    ```typescript title="src/workflows/user-signup.ts" lineNumbers
    import { sleep } from "workflow";

    export async function handleUserSignup(email: string) {
      "use workflow"; // [!code highlight]

      const user = await createUser(email);
      await sendWelcomeEmail(user);

      await sleep("5s"); // Pause for 5s - doesn't consume any resources
      await sendOnboardingEmail(user);

      return { userId: user.id, status: "onboarded" };
    }

    ```

    We'll fill in those functions next, but let's take a look at this code:

    * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the *orchestrator* of individual **steps**.
    * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long.

    ## Create Your Workflow Steps

    Let's now define those missing functions.

    ```typescript title="src/workflows/user-signup.ts" lineNumbers
    import { FatalError } from "workflow"

    // Our workflow function defined earlier

    async function createUser(email: string) {
      "use step"; // [!code highlight]

      console.log(`Creating user with email: ${email}`);

      // Full Node.js access - database calls, APIs, etc.
      return { id: crypto.randomUUID(), email };
    }

    async function sendWelcomeEmail(user: { id: string; email: string; }) {
      "use step"; // [!code highlight]

      console.log(`Sending welcome email to user: ${user.id}`);

      if (Math.random() < 0.3) {
      // By default, steps will be retried for unhandled errors
       throw new Error("Retryable!");
      }
    }

    async function sendOnboardingEmail(user: { id: string; email: string}) {
      "use step"; // [!code highlight]

      if (!user.email.includes("@")) {
        // To skip retrying, throw a FatalError instead
        throw new FatalError("Invalid Email");
      }

      console.log(`Sending onboarding email to user: ${user.id}`);
    }
    ```

    Taking a look at this code:

    * Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, just like `sleep`.
    * If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count).
    * Steps can throw a `FatalError` if an error is intentional and should not be retried.

    <Callout>
      We'll dive deeper into workflows, steps, and other ways to suspend or handle events in [Foundations](/docs/foundations).
    </Callout>
  </Step>

  <Step>
    ## Create Your Route Handler

    To invoke your new workflow, we'll have to add your workflow to a `POST` API route handler, `src/pages/api/signup.ts` with the following code:

    ```typescript title="src/pages/api/signup.ts"
    import type { APIRoute } from "astro";
    import { start } from "workflow/api";
    import { handleUserSignup } from "../../workflows/user-signup";

    export const POST: APIRoute = async ({ request }: { request: Request }) => {
      const { email } = await request.json();

      // Executes asynchronously and doesn't block your app
      await start(handleUserSignup, [email]);
      return Response.json({
        message: "User signup workflow started",
      });
    };

    export const prerender = false; // Don't prerender this page since it's an API route
    ```

    This route handler creates a `POST` request endpoint at `/api/signup` that will trigger your workflow.

    <Callout>
      Workflows can be triggered from API routes or any server-side code.
    </Callout>
  </Step>
</Steps>

## Run in Development

To start your development server, run the following command in your terminal in the Vite root directory:

```bash
npm run dev
```

Once your development server is running, you can trigger your workflow by running this command in the terminal:

```bash
curl -X POST --json '{"email":"hello@example.com"}' http://localhost:4321/api/signup
```

Check the Astro development server logs to see your workflow execute as well as the steps that are being processed.

Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.

```bash
npx workflow inspect runs
# or add '--web' for an interactive Web based UI
```

<img alt="Workflow SDK Web UI" src={__img0} placeholder="blur" />

***

## Deploying to Production

Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration.

<FluidComputeCallout />

To deploy your Astro project to Vercel, ensure that the [Astro Vercel adapter](https://docs.astro.build/en/guides/integrations-guide/vercel) is configured:

```bash
npx astro add vercel
```

Additionally, check the [Deploying](/docs/deploying) section to learn how your workflows can be deployed elsewhere.

## Troubleshooting

### `start()` says it received an invalid workflow function

If you see this error:

```
'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.
```

Check both of these first:

1. The workflow function includes `"use workflow"`.
2. Your `astro.config.mjs` includes the `workflow()` integration.

See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes.

## Next Steps

* Learn more about the [Foundations](/docs/foundations).
* Check [Errors](/docs/errors) if you encounter issues.
* Explore the [API Reference](/docs/api-reference).


---
title: Express
description: Set up your first durable workflow in an Express application.
type: guide
summary: Set up Workflow SDK in an Express app.
prerequisites:
  - /docs/getting-started
related:
  - /docs/foundations/workflows-and-steps
---

# Express





This guide will walk through setting up your first workflow in an Express app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects.

***

<Steps>
  <Step>
    ## Create Your Express Project

    Start by creating a new Express project.

    ```bash
    mkdir my-workflow-app
    ```

    Enter the newly made directory:

    ```bash
    cd my-workflow-app
    ```

    Initialize the project:

    ```bash
    npm init --y
    ```

    ### Install `workflow`, `express`, `nitro`, and `rollup`

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i workflow express nitro rollup
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add workflow express nitro rollup
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add workflow express nitro rollup
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add workflow express nitro rollup
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    <Callout>
      By default, Express doesn't include a build system. Nitro adds one which enables compiling workflows, runs, and deploys for development and production. Learn more about Nitro [here](https://v3.nitro.build).
    </Callout>

    If using TypeScript, you need to install the `@types/express` package.

    ```bash
    npm i -D @types/express
    ```

    ### Configure Nitro

    Create a new file `nitro.config.ts` for your Nitro configuration with module `workflow/nitro`. This enables usage of the `"use workflow"` and `"use step"` directives.

    ```typescript title="nitro.config.ts" lineNumbers
    import { defineNitroConfig } from "nitro/config";

    export default defineNitroConfig({
      modules: ["workflow/nitro"],
      vercel: { entryFormat: "node" },
      routes: {
        "/**": { handler: "./src/index.ts", format: "node" },
      },
    });
    ```

    <Accordion type="single" collapsible>
      <AccordionItem value="typescript-intellisense" className="[&_h3]:my-0">
        <AccordionTrigger className="[&_p]:my-0 text-lg [&_p]:text-foreground">
          Setup IntelliSense for TypeScript (Optional)
        </AccordionTrigger>

        <AccordionContent className="[&_p]:my-2">
          To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`:

          ```json title="tsconfig.json" lineNumbers
          {
            "compilerOptions": {
              // ... rest of your TypeScript config
              "plugins": [
                {
                  "name": "workflow" // [!code highlight]
                }
              ]
            }
          }
          ```
        </AccordionContent>
      </AccordionItem>
    </Accordion>

    ### Update `package.json`

    To use the Nitro builder, update your `package.json` to include the following scripts:

    ```json title="package.json" lineNumbers
    {
      // ...
      "scripts": {
        "dev": "nitro dev",
        "build": "nitro build"
      },
      // ...
    }
    ```
  </Step>

  <Step>
    ## Create Your First Workflow

    Create a new file for our first workflow:

    ```typescript title="workflows/user-signup.ts" lineNumbers
    import { sleep } from "workflow";

    export async function handleUserSignup(email: string) {
      "use workflow"; // [!code highlight]

      const user = await createUser(email);
      await sendWelcomeEmail(user);

      await sleep("5s"); // Pause for 5s - doesn't consume any resources
      await sendOnboardingEmail(user);

      return { userId: user.id, status: "onboarded" };
    }
    ```

    We'll fill in those functions next, but let's take a look at this code:

    * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the *orchestrator* of individual **steps**.
    * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long.

    ## Create Your Workflow Steps

    Let's now define those missing functions.

    ```typescript title="workflows/user-signup.ts" lineNumbers
    import { FatalError } from "workflow";

    // Our workflow function defined earlier

    async function createUser(email: string) {
      "use step"; // [!code highlight]

      console.log(`Creating user with email: ${email}`);

      // Full Node.js access - database calls, APIs, etc.
      return { id: crypto.randomUUID(), email };
    }

    async function sendWelcomeEmail(user: { id: string; email: string }) {
      "use step"; // [!code highlight]

      console.log(`Sending welcome email to user: ${user.id}`);

      if (Math.random() < 0.3) {
        // By default, steps will be retried for unhandled errors
        throw new Error("Retryable!");
      }
    }

    async function sendOnboardingEmail(user: { id: string; email: string }) {
      "use step"; // [!code highlight]

      if (!user.email.includes("@")) {
        // To skip retrying, throw a FatalError instead
        throw new FatalError("Invalid Email");
      }

      console.log(`Sending onboarding email to user: ${user.id}`);
    }
    ```

    Taking a look at this code:

    * Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, just like `sleep`.
    * If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count).
    * Steps can throw a `FatalError` if an error is intentional and should not be retried.

    <Callout>
      We'll dive deeper into workflows, steps, and other ways to suspend or handle
      events in [Foundations](/docs/foundations).
    </Callout>
  </Step>

  <Step>
    ## Create Your Route Handler

    To invoke your new workflow, we'll create both the Express app and a new API route handler at `src/index.ts` with the following code:

    ```typescript title="src/index.ts"
    import express from "express";
    import { start } from "workflow/api";
    import { handleUserSignup } from "../workflows/user-signup.js";

    const app = express();
    app.use(express.json());

    app.post("/api/signup", async (req, res) => {
      const { email } = req.body;
      await start(handleUserSignup, [email]);
      return res.json({ message: "User signup workflow started" });
    });

    export default app;
    ```

    This route handler creates a `POST` request endpoint at `/api/signup` that will trigger your workflow.
  </Step>

  <Step>
    ## Run in development

    To start your development server, run the following command in your terminal in the Express root directory:

    ```bash
    npm run dev
    ```

    Once your development server is running, you can trigger your workflow by running this command in the terminal:

    ```bash
    curl -X POST --json '{"email":"hello@example.com"}' http://localhost:3000/api/signup
    ```

    Check the Express development server logs to see your workflow execute as well as the steps that are being processed.

    Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.

    ```bash
    # Open the observability Web UI
    npx workflow web
    # or if you prefer a terminal interface, use the CLI inspect command
    npx workflow inspect runs
    ```

        <img alt="Workflow SDK Web UI" src={__img0} placeholder="blur" />
  </Step>
</Steps>

***

## Deploying to production

Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration.

<FluidComputeCallout />

Check the [Deploying](/docs/deploying) section to learn how your workflows can be deployed elsewhere.

## Troubleshooting

### `start()` says it received an invalid workflow function

If you see this error:

```
'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.
```

Check both of these first:

1. The workflow function includes `"use workflow"`.
2. Your Nitro config includes the `workflow/nitro` module.

See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes.

## Next Steps

* Learn more about the [Foundations](/docs/foundations).
* Check [Errors](/docs/errors) if you encounter issues.
* Explore the [API Reference](/docs/api-reference).


---
title: Fastify
description: Set up your first durable workflow in a Fastify application.
type: guide
summary: Set up Workflow SDK in a Fastify app.
prerequisites:
  - /docs/getting-started
related:
  - /docs/foundations/workflows-and-steps
---

# Fastify





This guide will walk through setting up your first workflow in a Fastify app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects.

***

<Steps>
  <Step>
    ## Create Your Fastify Project

    Start by creating a new Fastify project.

    ```bash
    mkdir my-workflow-app
    ```

    Enter the newly made directory:

    ```bash
    cd my-workflow-app
    ```

    Initialize the project:

    ```bash
    npm init --y
    ```

    ### Install `workflow`, `fastify` and `nitro`

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i workflow fastify nitro rollup
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add workflow fastify nitro rollup
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add workflow fastify nitro rollup
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add workflow fastify nitro rollup
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    <Callout>
      By default, Fastify doesn't include a build system. Nitro adds one which enables compiling workflows, runs, and deploys for development and production. Learn more about [Nitro](https://v3.nitro.build).
    </Callout>

    If using TypeScript, you need to install the `@types/node` and `typescript` packages

    ```bash
    npm i -D @types/node typescript
    ```

    ### Configure Nitro

    Create a new file `nitro.config.ts` for your Nitro configuration with module `workflow/nitro`. This enables usage of the `"use workflow"` and `"use step"` directives

    ```typescript title="nitro.config.ts" lineNumbers
    import { defineNitroConfig } from "nitro/config";

    export default defineNitroConfig({
    	modules: ["workflow/nitro"],
    	vercel: { entryFormat: "node" },
    	routes: {
    		"/**": { handler: "./src/index.ts", format: "node" },
    	},
    });
    ```

    <Accordion type="single" collapsible>
      <AccordionItem value="typescript-intellisense" className="[&_h3]:my-0">
        <AccordionTrigger className="[&_p]:my-0 text-lg [&_p]:text-foreground">
          Setup IntelliSense for TypeScript (Optional)
        </AccordionTrigger>

        <AccordionContent className="[&_p]:my-2">
          To enable helpful hints in your IDE, set up the workflow plugin in `tsconfig.json`:

          ```json title="tsconfig.json" lineNumbers
          {
            "compilerOptions": {
              // ... rest of your TypeScript config
              "plugins": [
                {
                  "name": "workflow" // [!code highlight]
                }
              ]
            }
          }
          ```
        </AccordionContent>
      </AccordionItem>
    </Accordion>

    ### Update `package.json`

    To use the Nitro builder, update your `package.json` to include the following scripts:

    ```json title="package.json" lineNumbers
    {
      // ...
      "scripts": {
        "dev": "nitro dev",
        "build": "nitro build"
      },
      // ...
    }
    ```
  </Step>

  <Step>
    ## Create Your First Workflow

    Create a new file for our first workflow:

    ```typescript title="workflows/user-signup.ts" lineNumbers
    import { sleep } from "workflow";

    export async function handleUserSignup(email: string) {
      "use workflow"; // [!code highlight]

      const user = await createUser(email);
      await sendWelcomeEmail(user);

      await sleep("5s"); // Pause for 5s - doesn't consume any resources
      await sendOnboardingEmail(user);

      return { userId: user.id, status: "onboarded" };
    }
    ```

    We'll fill in those functions next, but let's take a look at this code:

    * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the *orchestrator* of individual **steps**.
    * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long.

    ## Create Your Workflow Steps

    Let's now define those missing functions:

    ```typescript title="workflows/user-signup.ts" lineNumbers
    import { FatalError } from "workflow";

    // Our workflow function defined earlier

    async function createUser(email: string) {
      "use step"; // [!code highlight]
      console.log(`Creating user with email: ${email}`);
      return { id: crypto.randomUUID(), email };
    }

    async function sendWelcomeEmail(user: { id: string; email: string }) {
      "use step"; // [!code highlight]
      console.log(`Sending welcome email to user: ${user.id}`);
      if (Math.random() < 0.3) {
        // Steps retry on unhandled errors
        throw new Error("Retryable!");
      }
    }

    async function sendOnboardingEmail(user: { id: string; email: string }) {
      "use step"; // [!code highlight]
      if (!user.email.includes("@")) {
        // FatalError skips retries
        throw new FatalError("Invalid Email");
      }
      console.log(`Sending onboarding email to user: ${user.id}`);
    }
    ```

    Taking a look at this code:

    * Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, just like `sleep`.
    * If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count).
    * Steps can throw a `FatalError` if an error is intentional and should not be retried.

    <Callout>
      We'll dive deeper into workflows, steps, and other ways to suspend or handle
      events in [Foundations](/docs/foundations).
    </Callout>
  </Step>

  <Step>
    ## Create Your Route Handler

    To invoke your new workflow, we'll create both the Fastify app and a new API route handler at `src/index.ts` with the following code:

    ```typescript title="src/index.ts"
    import Fastify from "fastify";
    import { start } from "workflow/api";
    import { handleUserSignup } from "../workflows/user-signup.js";

    const app = Fastify({ logger: true });
    app.post("/api/signup", async (req, reply) => {
      const { email } = req.body as { email: string };
      await start(handleUserSignup, [email]);
      return reply.send({ message: "User signup workflow started" });
    });

    // Wait for Fastify to be ready before handling requests
    await app.ready();


    export default (req: any, res: any) => {
      app.server.emit("request", req, res);
    };
    ```

    This route handler creates a `POST` request endpoint at `/api/signup` that will trigger your workflow.
  </Step>

  <Step>
    ## Run in development

    To start your development server, run the following command in your terminal in the Fastify root directory:

    ```bash
    npm run dev
    ```

    Once your development server is running, you can trigger your workflow by running this command in the terminal:

    ```bash
    curl -X POST --json '{"email":"hello@example.com"}' http://localhost:3000/api/signup
    ```

    Check the Fastify development server logs to see your workflow execute as well as the steps that are being processed.

    Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.

    ```bash
    npx workflow inspect runs # add '--web' for an interactive Web based UI
    ```

        <img alt="Workflow SDK Web UI" src={__img0} placeholder="blur" />
  </Step>
</Steps>

***

## Deploying to production

Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration.

<FluidComputeCallout />

Check the [Deploying](/docs/deploying) section to learn how your workflows can be deployed elsewhere.

## Troubleshooting

### `start()` says it received an invalid workflow function

If you see this error:

```
'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.
```

Check both of these first:

1. The workflow function includes `"use workflow"`.
2. Your Nitro config includes the `workflow/nitro` module.

See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes.

## Next Steps

* Learn more about the [Foundations](/docs/foundations).
* Check [Errors](/docs/errors) if you encounter issues.
* Explore the [API Reference](/docs/api-reference).


---
title: Hono
description: This guide will walk through setting up your first workflow in a Hono app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects.
type: guide
summary: Set up Workflow SDK in a Hono app.
prerequisites:
  - /docs/getting-started
related:
  - /docs/foundations/workflows-and-steps
---

# Hono





<Steps>
  <Step>
    ## Create Your Hono Project

    Start by creating a new Hono project. This command will create a new directory named `my-workflow-app` and set up a Hono project inside it.

    ```bash
    npm create hono@latest my-workflow-app -- --template=nodejs
    ```

    Enter the newly created directory:

    ```bash
    cd my-workflow-app
    ```

    ### Install `workflow`, `nitro`, and `rollup`

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i workflow nitro rollup
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add workflow nitro rollup
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add workflow nitro rollup
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add workflow nitro rollup
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    <Callout>
      By default, Hono doesn't include a build system. Nitro adds one which enables compiling workflows, runs, and deploys for development and production. Learn more about Nitro [here](https://v3.nitro.build).
    </Callout>

    ### Configure Nitro

    Create a new file `nitro.config.ts` for your Nitro configuration with module `workflow/nitro`. This enables usage of the `"use workflow"` and `"use step"` directives.

    ```typescript title="nitro.config.ts" lineNumbers
    import { defineConfig } from "nitro";

    export default defineConfig({
      modules: ["workflow/nitro"],
      routes: {
        "/**": "./src/index.ts"
      }
    });
    ```

    <Accordion type="single" collapsible>
      <AccordionItem value="typescript-intellisense" className="[&_h3]:my-0">
        <AccordionTrigger className="[&_p]:my-0 text-lg [&_p]:text-foreground">
          Setup IntelliSense for TypeScript (Optional)
        </AccordionTrigger>

        <AccordionContent className="[&_p]:my-2">
          To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`:

          ```json title="tsconfig.json" lineNumbers
          {
            "compilerOptions": {
              // ... rest of your TypeScript config
              "plugins": [
                {
                  "name": "workflow" // [!code highlight]
                }
              ]
            }
          }
          ```
        </AccordionContent>
      </AccordionItem>
    </Accordion>

    ### Update `package.json`

    To use the Nitro builder, update your `package.json` to include the following scripts:

    ```json title="package.json" lineNumbers
    {
      // ...
      "scripts": {
        "dev": "nitro dev",
        "build": "nitro build"
      },
      // ...
    }
    ```
  </Step>

  <Step>
    ## Create Your First Workflow

    Create a new file for our first workflow:

    ```typescript title="workflows/user-signup.ts" lineNumbers
    import { sleep } from "workflow";

    export async function handleUserSignup(email: string) {
      "use workflow"; // [!code highlight]

      const user = await createUser(email);
      await sendWelcomeEmail(user);

      await sleep("5s"); // Pause for 5s - doesn't consume any resources
      await sendOnboardingEmail(user);

      console.log("Workflow is complete! Run 'npx workflow web' to inspect your run")

      return { userId: user.id, status: "onboarded" };
    }
    ```

    We'll fill in those functions next, but let's take a look at this code:

    * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the *orchestrator* of individual **steps**.
    * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long.

    ## Create Your Workflow Steps

    Let's now define those missing functions.

    ```typescript title="workflows/user-signup.ts" lineNumbers
    import { FatalError } from "workflow";

    // Our workflow function defined earlier

    async function createUser(email: string) {
      "use step"; // [!code highlight]

      console.log(`Creating user with email: ${email}`);

      // Full Node.js access - database calls, APIs, etc.
      return { id: crypto.randomUUID(), email };
    }

    async function sendWelcomeEmail(user: { id: string; email: string }) {
      "use step"; // [!code highlight]

      console.log(`Sending welcome email to user: ${user.id}`);

      if (Math.random() < 0.3) {
        // By default, steps will be retried for unhandled errors
        throw new Error("Retryable!");
      }
    }

    async function sendOnboardingEmail(user: { id: string; email: string }) {
      "use step"; // [!code highlight]

      if (!user.email.includes("@")) {
        // To skip retrying, throw a FatalError instead
        throw new FatalError("Invalid Email");
      }

      console.log(`Sending onboarding email to user: ${user.id}`);
    }
    ```

    Taking a look at this code:

    * Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, just like `sleep`.
    * If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count).
    * Steps can throw a `FatalError` if an error is intentional and should not be retried.

    <Callout>
      We'll dive deeper into workflows, steps, and other ways to suspend or handle
      events in [Foundations](/docs/foundations).
    </Callout>
  </Step>

  <Step>
    ## Create Your Route Handler

    To invoke your new workflow, we'll create a new API route handler at `src/index.ts` with the following code:

    ```typescript title="src/index.ts"
    import { Hono } from "hono";
    import { start } from "workflow/api";
    import { handleUserSignup } from "../workflows/user-signup.js";

    const app = new Hono();

    app.post("/api/signup", async (c) => {
      const { email } = await c.req.json();
      await start(handleUserSignup, [email]);
      return c.json({ message: "User signup workflow started" });
    });

    export default app;
    ```

    This route handler creates a `POST` request endpoint at `/api/signup` that will trigger your workflow.
  </Step>

  <Step>
    ## Run in development

    To start your development server, run the following command in your terminal in the Hono root directory:

    ```bash
    npm run dev
    ```

    Once your development server is running, you can trigger your workflow by running this command in the terminal:

    ```bash
    curl -X POST --json '{"email":"hello@example.com"}' http://localhost:3000/api/signup
    ```

    Check the Hono development server logs to see your workflow execute as well as the steps that are being processed.

    Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.

    ```bash
    # Open the observability Web UI
    npx workflow web
    # or if you prefer a terminal interface, use the CLI inspect command
    npx workflow inspect runs
    ```

        <img alt="Workflow SDK Web UI" src={__img0} placeholder="blur" />
  </Step>
</Steps>

## Deploying to production

Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration.

<FluidComputeCallout />

Check the [Deploying](/docs/deploying) section to learn how your workflows can be deployed elsewhere.

## Troubleshooting

### `start()` says it received an invalid workflow function

If you see this error:

```
'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.
```

Check both of these first:

1. The workflow function includes `"use workflow"`.
2. Your Nitro config includes the `workflow/nitro` module.

See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes.

## Next Steps

* Learn more about the [Foundations](/docs/foundations).
* Check [Errors](/docs/errors) if you encounter issues.
* Explore the [API Reference](/docs/api-reference).


---
title: Getting Started
description: Start by choosing your framework. Each guide will walk you through the steps to install the dependencies and start running your first workflow.
type: overview
summary: Choose a framework and start building your first workflow.
related:
  - /docs/foundations
  - /docs/foundations/workflows-and-steps
---

# Getting Started



import { Next, Nitro, SvelteKit, Nuxt, Hono, Bun, AstroDark, AstroLight, TanStack, Vite, Express, Nest, Fastify, Python } from "@/app/[lang]/(home)/components/frameworks";

<Cards>
  <Card href="/docs/getting-started/next">
    <div className="flex flex-col items-center justify-center gap-2">
       

      <Next className="size-16" />

       

      <span className="font-medium">Next.js</span>

       
    </div>
  </Card>

  <Card href="/docs/getting-started/vite">
    <div className="flex flex-col items-center justify-center gap-2">
      <Vite className="size-16" />

      <span className="font-medium">
        Vite
      </span>
    </div>
  </Card>

  <Card href="/docs/getting-started/astro">
    <div className="flex flex-col items-center justify-center gap-2">
      <AstroLight className="size-16 dark:hidden" />

      <AstroDark className="size-16 hidden dark:block" />

      <span className="font-medium">
        Astro
      </span>
    </div>
  </Card>

  <Card href="/docs/getting-started/express">
    <div className="flex flex-col items-center justify-center gap-2">
      <Express className="size-16 dark:invert" />

      <span className="font-medium">
        Express
      </span>
    </div>
  </Card>

  <Card href="/docs/getting-started/fastify">
    <div className="flex flex-col items-center justify-center text-center gap-2">
      <Fastify className="size-16 dark:invert" />

      <span className="font-medium">
        Fastify
      </span>
    </div>
  </Card>

  <Card href="/docs/getting-started/hono">
    <div className="flex flex-col items-center justify-center gap-2">
      <Hono className="size-16" />

      <span className="font-medium">
        Hono
      </span>
    </div>
  </Card>

  <Card href="/docs/getting-started/nitro">
    <div className="flex flex-col items-center justify-center gap-2">
      <Nitro className="size-16" />

      <span className="font-medium">
        Nitro
      </span>
    </div>
  </Card>

  <Card href="/docs/getting-started/nuxt">
    <div className="flex flex-col items-center justify-center gap-2">
      <Nuxt className="size-16" />

      <span className="font-medium">
        Nuxt
      </span>
    </div>
  </Card>

  <Card href="/docs/getting-started/sveltekit">
    <div className="flex flex-col items-center justify-center gap-2">
      <SvelteKit className="size-16" />

      <span className="font-medium">
        SvelteKit
      </span>
    </div>
  </Card>

  <Card href="/docs/getting-started/tanstack-start">
    <div className="flex flex-col items-center justify-center gap-2">
      <TanStack className="size-16 dark:invert" />

      <span className="font-medium">
        TanStack Start
      </span>
    </div>
  </Card>

  <Card href="/docs/getting-started/python">
    <div className="flex flex-col items-center justify-center gap-2">
      <Python className="size-16" />

      <span className="font-medium">
        Python
      </span>

      <Badge variant="secondary">
        Beta
      </Badge>
    </div>
  </Card>

  <Card className="opacity-50">
    <div className="flex flex-col items-center justify-center gap-2">
      <Nest className="size-16 dark:invert grayscale" />

      <span className="font-medium">
        NestJS
      </span>

      <Badge variant="secondary">
        Coming soon
      </Badge>
    </div>
  </Card>
</Cards>


---
title: NestJS
description: Set up your first durable workflow in a NestJS application.
type: guide
summary: Set up Workflow SDK in a NestJS app.
prerequisites:
  - /docs/getting-started
related:
  - /docs/foundations/workflows-and-steps
---

# NestJS





This guide will walk through setting up your first workflow in a NestJS app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects.

<Callout>
  NestJS integration is experimental and not yet supported for deployment to Vercel.
</Callout>

***

<Steps>
  <Step>
    ## Create Your NestJS Project

    Start by creating a new NestJS project using the NestJS CLI.

    ```bash
    npm i -g @nestjs/cli
    nest new my-workflow-app
    ```

    Enter the newly made directory:

    ```bash
    cd my-workflow-app
    ```

    ### Install `workflow`

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i workflow @workflow/nest
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add workflow @workflow/nest
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add workflow @workflow/nest
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add workflow @workflow/nest
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    ### Choose Your Module Format

    NestJS projects using `@workflow/nest` can compile as either ESM or CommonJS. Choose the setup that matches your SWC output instead of assuming ESM is required.

    #### ESM (default)

    Use this when your NestJS project is configured as an ES module app.

    ```json title="package.json" lineNumbers
    {
      "name": "my-workflow-app",
      "type": "module"
    }
    ```

    <Callout>
      When using ESM with NestJS, local imports must include the `.js` extension (e.g., `import { AppModule } from './app.module.js'`). This applies even though your source files are `.ts`.
    </Callout>

    #### CommonJS

    Use this when your NestJS project compiles CommonJS via SWC.

    ```typescript title="src/app.module.ts" lineNumbers
    import { Module } from '@nestjs/common';
    import { WorkflowModule } from '@workflow/nest';

    @Module({
      imports: [
        WorkflowModule.forRoot({
          moduleType: 'commonjs',
          distDir: 'dist',
        }),
      ],
    })
    export class AppModule {}
    ```

    <Callout type="info">
      `distDir` should match the directory where NestJS writes compiled `.js` files. In the default SWC setup, that is `dist`.
    </Callout>

    ### Configure NestJS to use SWC

    NestJS supports SWC as an alternative compiler for faster builds. The Workflow SDK uses an SWC plugin to transform workflow files.

    Install the required SWC packages:

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i -D @swc/cli @swc/core
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add -D @swc/cli @swc/core
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add --dev @swc/cli @swc/core
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add --dev @swc/cli @swc/core
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    Ensure your `nest-cli.json` has SWC as the builder:

    ```json title="nest-cli.json" lineNumbers
    {
      "$schema": "https://json.schemastore.org/nest-cli",
      "collection": "@nestjs/schematics",
      "sourceRoot": "src",
      "compilerOptions": {
        "builder": "swc",
        "deleteOutDir": true
      }
    }
    ```

    ### Initialize SWC Configuration

    Run the init command to generate the SWC configuration:

    ```bash
    npx @workflow/nest init
    ```

    This creates a `.swcrc` file configured with the Workflow SWC plugin for client-mode transformations.

    <Callout>
      Add `.swcrc` to your `.gitignore` as it contains machine-specific absolute paths that shouldn't be committed.
    </Callout>

    ### Update `package.json`

    Add scripts to regenerate the SWC configuration before builds:

    ```json title="package.json" lineNumbers
    {
      "scripts": {
        "prebuild": "npx @workflow/nest init --force",
        "build": "nest build",
        "start:dev": "npx @workflow/nest init --force && nest start --watch"
      }
    }
    ```

    <Accordion type="single" collapsible>
      <AccordionItem value="typescript-intellisense" className="[&_h3]:my-0">
        <AccordionTrigger className="[&_p]:my-0 text-lg [&_p]:text-foreground">
          Setup IntelliSense for TypeScript (Optional)
        </AccordionTrigger>

        <AccordionContent className="[&_p]:my-2">
          To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`:

          ```json title="tsconfig.json" lineNumbers
          {
            "compilerOptions": {
              // ... rest of your TypeScript config
              "plugins": [
                {
                  "name": "workflow" // [!code highlight]
                }
              ]
            }
          }
          ```
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  </Step>

  <Step>
    ## Import the WorkflowModule

    In your `app.module.ts`, import the `WorkflowModule` and keep the module format you chose above.

    ```typescript title="src/app.module.ts" lineNumbers
    import { Module } from '@nestjs/common';
    import { WorkflowModule } from '@workflow/nest';
    import { AppController } from './app.controller.js';
    import { AppService } from './app.service.js';

    @Module({
      imports: [WorkflowModule.forRoot()], // [!code highlight]
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}
    ```

    <Callout type="info">
      If you chose CommonJS above, keep the CommonJS options here as well:

      {/* @skip-typecheck - config snippet, full import shown above */}

      ```typescript
      WorkflowModule.forRoot({
        moduleType: 'commonjs',
        distDir: 'dist',
      })
      ```

      The `.js` local import specifiers in this example are the ESM form.
    </Callout>

    The `WorkflowModule` handles workflow bundle building and provides HTTP routing for workflow execution at `.well-known/workflow/v1/`.
  </Step>

  <Step>
    ## Create Your First Workflow

    Create a new file for our first workflow in the `src/workflows` directory:

    <Callout>
      Workflow files must be inside the `src/` directory so they get compiled with the SWC plugin that enables the `start()` function to work correctly.
    </Callout>

    <Callout type="info">
      If `start()` says it received an invalid workflow function, check both of these first:

      1. The workflow function includes `"use workflow"`.
      2. The workflow file lives inside `src/` so NestJS compiles it with the SWC plugin.

      See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes.
    </Callout>

    ```typescript title="src/workflows/user-signup.ts" lineNumbers
    import { sleep } from "workflow";

    export async function handleUserSignup(email: string) {
      "use workflow"; // [!code highlight]

      const user = await createUser(email);
      await sendWelcomeEmail(user);

      await sleep("5s"); // Pause for 5s - doesn't consume any resources
      await sendOnboardingEmail(user);

      return { userId: user.id, status: "onboarded" };
    }
    ```

    We'll fill in those functions next, but let's take a look at this code:

    * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the *orchestrator* of individual **steps**.
    * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long.

    ## Create Your Workflow Steps

    Let's now define those missing functions.

    ```typescript title="src/workflows/user-signup.ts" lineNumbers
    import { FatalError } from "workflow";

    // Our workflow function defined earlier

    async function createUser(email: string) {
      "use step"; // [!code highlight]

      console.log(`Creating user with email: ${email}`);

      // Full Node.js access - database calls, APIs, etc.
      return { id: crypto.randomUUID(), email };
    }

    async function sendWelcomeEmail(user: { id: string; email: string }) {
      "use step"; // [!code highlight]

      console.log(`Sending welcome email to user: ${user.id}`);

      if (Math.random() < 0.3) {
        // By default, steps will be retried for unhandled errors
        throw new Error("Retryable!");
      }
    }

    async function sendOnboardingEmail(user: { id: string; email: string }) {
      "use step"; // [!code highlight]

      if (!user.email.includes("@")) {
        // To skip retrying, throw a FatalError instead
        throw new FatalError("Invalid Email");
      }

      console.log(`Sending onboarding email to user: ${user.id}`);
    }
    ```

    Taking a look at this code:

    * Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, just like `sleep`.
    * If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count).
    * Steps can throw a `FatalError` if an error is intentional and should not be retried.

    <Callout>
      We'll dive deeper into workflows, steps, and other ways to suspend or handle
      events in [Foundations](/docs/foundations).
    </Callout>
  </Step>

  <Step>
    ## Create Your Controller

    To invoke your new workflow, update your controller with a new endpoint:

    {/*@skip-typecheck - NestJS decorators require special TypeScript config*/}

    ```typescript title="src/app.controller.ts" lineNumbers
    import { Body, Controller, Post } from '@nestjs/common';
    import { start } from 'workflow/api';
    import { handleUserSignup } from './workflows/user-signup.js';

    @Controller()
    export class AppController {
      @Post('signup')
      async signup(@Body() body: { email: string }) {
        await start(handleUserSignup, [body.email]);
        return { message: 'User signup workflow started' };
      }
    }
    ```

    <Callout type="info">
      If you chose CommonJS above, use the same local import style as the rest of your NestJS app here too. The `.js` extension shown in this example is the ESM form.
    </Callout>

    This creates a `POST` endpoint at `/signup` that will trigger your workflow.
  </Step>

  <Step>
    ## Run in development

    To start your development server, run the following command in your terminal:

    ```bash
    npm run start:dev
    ```

    Once your development server is running, you can trigger your workflow by running this command in the terminal:

    ```bash
    curl -X POST -H "Content-Type: application/json" -d '{"email":"hello@example.com"}' http://localhost:3000/signup
    ```

    Check the NestJS development server logs to see your workflow execute as well as the steps that are being processed.

    Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.

    ```bash
    # Open the observability Web UI
    npx workflow web
    # or if you prefer a terminal interface, use the CLI inspect command
    npx workflow inspect runs
    ```

        <img alt="Workflow SDK Web UI" src={__img0} placeholder="blur" />
  </Step>
</Steps>

***

## Configuration Options

The `WorkflowModule.forRoot()` method accepts optional configuration:

{/*@skip-typecheck - Configuration snippet, WorkflowModule not imported*/}

```typescript
WorkflowModule.forRoot({
  // Directory to scan for workflow files (default: ['src'])
  dirs: ['src'],

  // Output directory for generated bundles (default: '.nestjs/workflow')
  outDir: '.nestjs/workflow',

  // Skip building in production when bundles are pre-built
  skipBuild: false,

  // SWC module type: 'es6' (default) or 'commonjs'
  // Set to 'commonjs' if your NestJS project compiles to CJS via SWC
  moduleType: 'es6',

  // Directory where NestJS compiles .ts to .js (default: 'dist')
  // Only used when moduleType is 'commonjs'
  // Should match the outDir in your tsconfig.json
  distDir: 'dist',

  // Source maps on generated workflow bundles (default: 'inline' in
  // development, false in production).
  // Accepts the same values as esbuild's sourcemap option: true, false,
  // 'inline', 'linked', 'external', 'both'. Set to false for smaller
  // function bundles (useful for staying under the Vercel 250MB function
  // size limit) at the cost of stack traces pointing at generated code.
  // Can also be set via the WORKFLOW_SOURCEMAP environment variable.
  sourcemap: 'inline',
});
```

## Troubleshooting

### `start()` says it received an invalid workflow function

If you see this error:

```
'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.
```

Check both of these first:

1. The workflow function includes `"use workflow"`.
2. Your NestJS app imports and registers the `WorkflowModule`.

See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes.

## Next Steps

* Learn more about the [Foundations](/docs/foundations).
* Check [Errors](/docs/errors) if you encounter issues.
* Explore the [API Reference](/docs/api-reference).


---
title: Next.js
description: This guide will walk through setting up your first workflow in a Next.js app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects.
type: guide
summary: Set up Workflow SDK in a Next.js app.
prerequisites:
  - /docs/getting-started
related:
  - /docs/api-reference/workflow-next
  - /docs/deploying/world/vercel-world
---

# Next.js





<Steps>
  <Step>
    ## Create Your Next.js Project

    Start by creating a new Next.js project. This command will create a new directory named `my-workflow-app` and set up a Next.js project inside it.

    ```bash
    npm create next-app@latest my-workflow-app
    ```

    Enter the newly created directory:

    ```bash
    cd my-workflow-app
    ```

    ### Install `workflow`

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add workflow
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    ### Configure Next.js

    Wrap your `next.config.ts` with `withWorkflow()`. This enables usage of the `"use workflow"` and `"use step"` directives.

    ```typescript title="next.config.ts" lineNumbers
    import { withWorkflow } from "workflow/next"; // [!code highlight]
    import type { NextConfig } from "next";

    const nextConfig: NextConfig = {
      // … rest of your Next.js config
    };

    export default withWorkflow(nextConfig); // [!code highlight]
    ```

    <Accordion type="single" collapsible>
      <AccordionItem value="typescript-intellisense" className="[&_h3]:my-0">
        <AccordionTrigger className="text-sm">
          ### Setup IntelliSense for TypeScript (Optional)
        </AccordionTrigger>

        <AccordionContent className="[&_p]:my-2">
          To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`:

          ```json title="tsconfig.json" lineNumbers
          {
            "compilerOptions": {
              // ... rest of your TypeScript config
              "plugins": [
                {
                  "name": "workflow" // [!code highlight]
                }
              ]
            }
          }
          ```
        </AccordionContent>
      </AccordionItem>
    </Accordion>

    <Accordion type="single" collapsible>
      <AccordionItem value="configure-proxy-handler" className="[&_h3]:my-0">
        <AccordionTrigger className="text-sm">
          <h3 id="configure-proxy-handler">
            Configure Proxy Handler (if applicable)
          </h3>
        </AccordionTrigger>

        <AccordionContent className="[&_p]:my-2">
          If your Next.js app has a [proxy handler](https://nextjs.org/docs/app/api-reference/file-conventions/proxy)
          (formerly known as "middleware"), you'll need to update the matcher pattern to exclude Workflow's
          internal paths to prevent the proxy handler from running on them.

          If you see `[local world] Queue operation failed` with `Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer`, your proxy matcher is still intercepting Workflow's internal `POST /.well-known/workflow/v1/flow` request. This is especially easy to miss in Next.js 16, where `proxy.ts` replaced `middleware.ts`.

          Add `.well-known/workflow/*` to your matcher exclusion list:

          ```typescript title="proxy.ts" lineNumbers
          import { NextResponse } from "next/server";
          import type { NextRequest } from "next/server";

          export function proxy(request: NextRequest) {
            // Your middleware logic
            return NextResponse.next();
          }

          export const config = {
            matcher: [
              // ... your existing matchers
              {
                source: "/((?!_next/static|_next/image|favicon.ico|.well-known/workflow/).*)", // [!code highlight]
              },
            ],
          };
          ```

          This ensures that internal Workflow paths are not intercepted by your middleware, which could interfere with workflow execution and resumption.
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  </Step>

  <Step>
    ## Create Your First Workflow

    Create a new file for our first workflow:

    ```typescript title="workflows/user-signup.ts" lineNumbers
    import { sleep } from "workflow";

    export async function handleUserSignup(email: string) {
     "use workflow"; // [!code highlight]

     const user = await createUser(email);
     await sendWelcomeEmail(user);

     await sleep("5s"); // Pause for 5s - doesn't consume any resources
     await sendOnboardingEmail(user);

     console.log("Workflow is complete! Run 'npx workflow web' to inspect your run")

     return { userId: user.id, status: "onboarded" };
    }

    ```

    We'll fill in those functions next, but let's take a look at this code:

    * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the *orchestrator* of individual **steps**.
    * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long.

    ## Create Your Workflow Steps

    Let's now define those missing functions.

    ```typescript title="workflows/user-signup.ts" lineNumbers
    import { FatalError } from "workflow"

    // Our workflow function defined earlier

    async function createUser(email: string) {
      "use step"; // [!code highlight]

      console.log(`Creating user with email: ${email}`);

      // Full Node.js access - database calls, APIs, etc.
      return { id: crypto.randomUUID(), email };
    }

    async function sendWelcomeEmail(user: { id: string; email: string; }) {
      "use step"; // [!code highlight]

      console.log(`Sending welcome email to user: ${user.id}`);

      if (Math.random() < 0.3) {
      // By default, steps will be retried for unhandled errors
       throw new Error("Retryable!");
      }
    }

    async function sendOnboardingEmail(user: { id: string; email: string}) {
     "use step"; // [!code highlight]

      if (!user.email.includes("@")) {
        // To skip retrying, throw a FatalError instead
        throw new FatalError("Invalid Email");
      }

     console.log(`Sending onboarding email to user: ${user.id}`);
    }
    ```

    Taking a look at this code:

    * Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, just like `sleep`.
    * If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count).
    * Steps can throw a `FatalError` if an error is intentional and should not be retried.

    <Callout>
      We'll dive deeper into workflows, steps, and other ways to suspend or handle events in [Foundations](/docs/foundations).
    </Callout>
  </Step>

  <Step>
    ## Create Your Route Handler

    To invoke your new workflow, we'll need to add your workflow to a `POST` API Route Handler, `app/api/signup/route.ts`, with the following code:

    ```typescript title="app/api/signup/route.ts"
    import { start } from "workflow/api";
    import { handleUserSignup } from "@/workflows/user-signup";
    import { NextResponse } from "next/server";

    export async function POST(request: Request) {
     const { email } = await request.json();

     // Executes asynchronously and doesn't block your app
     await start(handleUserSignup, [email]);

     return NextResponse.json({
      message: "User signup workflow started",
     });
    }
    ```

    This Route Handler creates a `POST` request endpoint at `/api/signup` that will trigger your workflow.

    <Callout>
      Workflows can be triggered from API routes, Server Actions, or any server-side code.
    </Callout>
  </Step>
</Steps>

## Run in development

To start your development server, run the following command in your terminal in the Next.js root directory:

```bash
npm run dev
```

Once your development server is running, you can trigger your workflow by running this command in the terminal:

```bash
curl -X POST --json '{"email":"hello@example.com"}' http://localhost:3000/api/signup
```

Check the Next.js development server logs to see your workflow execute, as well as the steps that are being processed.

Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.

```bash
# Open the observability Web UI
npx workflow web
# or if you prefer a terminal interface, use the CLI inspect command
npx workflow inspect runs
```

<img alt="Workflow SDK Web UI" src={__img0} placeholder="blur" />

## Deploying to production

Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and need no special configuration.

<FluidComputeCallout />

Check the [Deploying](/docs/deploying) section to learn how your workflows can be deployed elsewhere.

## Troubleshooting

### Next.js 16.1+ compatibility

If you see this error when upgrading to Next.js 16.1 or later:

```
Build error occurred
Error: Cannot find module 'next/dist/lib/server-external-packages.json'
```

Upgrade to `workflow@4.0.1-beta.26` or later:

<CodeBlockTabs defaultValue="npm">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="npm">
      npm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="pnpm">
      pnpm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="yarn">
      yarn
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="bun">
      bun
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="npm">
    ```bash
    npm install workflow@latest
    ```
  </CodeBlockTab>

  <CodeBlockTab value="pnpm">
    ```bash
    pnpm add workflow@latest
    ```
  </CodeBlockTab>

  <CodeBlockTab value="yarn">
    ```bash
    yarn add workflow@latest
    ```
  </CodeBlockTab>

  <CodeBlockTab value="bun">
    ```bash
    bun add workflow@latest
    ```
  </CodeBlockTab>
</CodeBlockTabs>

### Turborepo caching

If you're using [Turborepo](https://turbo.build/repo) in a monorepo, you need to include the generated Workflow routes in your cache outputs. The Workflow SDK generates route handlers at `app/.well-known/workflow/` (or `src/app/.well-known/workflow/` if your project uses the `src` directory) during the build process, and these files must be cached alongside your Next.js build output.

Add the following to your `turbo.json`:

```jsonc title="turbo.json"
{
  "tasks": {
    "build": {
      "outputs": [
        ".next/**",
        "!.next/cache/**",
        // Include whichever path matches your project layout
        "app/.well-known/workflow/**",
        "src/app/.well-known/workflow/**"
      ]
    }
  }
}
```

Without this configuration, you may experience intermittent issues where workflows fail to register properly on cache hits, while working correctly on cache misses.

### `start()` says it received an invalid workflow function

If you see this error:

```
'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.
```

Check both of these first:

1. The workflow function includes `"use workflow"`.
2. Your `next.config.ts` is wrapped with [`withWorkflow()`](/docs/api-reference/workflow-next/with-workflow).

See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes.

## Next Steps

* Learn more about the [Foundations](/docs/foundations).
* Check [Errors](/docs/errors) if you encounter issues.
* Explore the [API Reference](/docs/api-reference).


---
title: Nitro
description: This guide will walk through setting up your first workflow in a Nitro v3 project. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects.
type: guide
summary: Set up Workflow SDK in a Nitro app.
prerequisites:
  - /docs/getting-started
related:
  - /docs/foundations/workflows-and-steps
---

# Nitro





<Steps>
  <Step>
    ## Create Your Nitro Project

    Start by creating a new [Nitro v3](https://v3.nitro.build/) project. This command will create a new directory named `nitro-app` and setup a Nitro project inside it.

    ```bash
    npx create-nitro-app
    ```

    Enter the newly made directory:

    ```bash
    cd nitro-app
    ```

    ### Install `workflow`

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add workflow
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    ### Configure Nitro

    Add `workflow/nitro` module to your `nitro.config.ts` This enables usage of the `"use workflow"` and `"use step"` directives.

    ```typescript title="nitro.config.ts" lineNumbers
    import { defineConfig } from "nitro";

    export default defineConfig({
      serverDir: "./server",
      modules: ["workflow/nitro"],
    });

    ```

    ### Module options

    The `workflow/nitro` module reads its options from `workflow` on your Nitro config.

    ```typescript title="nitro.config.ts" lineNumbers
    import { defineConfig } from "nitro";

    export default defineConfig({
      modules: ["workflow/nitro"],
      workflow: {
        runtime: "nodejs22.x",
        sourcemap: "inline",
      },
    });
    ```

    | Option      | Type                                                      | Default                           | Description                                                                                                                                                                                                                                                                                                                                                                  |
    | ----------- | --------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `dirs`      | `string[]`                                                | —                                 | Directories to scan for workflows and steps. By default, `workflows/` is scanned from the project root and all layer source directories.                                                                                                                                                                                                                                     |
    | `runtime`   | `string`                                                  | `'nodejs22.x'`                    | Node.js runtime version for Vercel Functions (e.g. `'nodejs22.x'`, `'nodejs24.x'`).                                                                                                                                                                                                                                                                                          |
    | `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles — helps stay under the Vercel 250MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. |

    <Accordion type="single" collapsible>
      <AccordionItem value="typescript-intellisense" className="[&_h3]:my-0">
        <AccordionTrigger className="[&_p]:my-0 text-lg [&_p]:text-foreground">
          Setup IntelliSense for TypeScript (Optional)
        </AccordionTrigger>

        <AccordionContent className="[&_p]:my-2">
          To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`:

          ```json title="tsconfig.json" lineNumbers
          {
            "compilerOptions": {
              // ... rest of your TypeScript config
              "plugins": [
                {
                  "name": "workflow" // [!code highlight]
                }
              ]
            }
          }
          ```
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  </Step>

  <Step>
    ## Create Your First Workflow

    Create a new file for our first workflow:

    ```typescript title="workflows/user-signup.ts" lineNumbers
    import { sleep } from "workflow";

    export async function handleUserSignup(email: string) {
      "use workflow"; // [!code highlight]

      const user = await createUser(email);
      await sendWelcomeEmail(user);

      await sleep("5s"); // Pause for 5s - doesn't consume any resources
      await sendOnboardingEmail(user);

      console.log("Workflow is complete! Run 'npx workflow web' to inspect your run")

      return { userId: user.id, status: "onboarded" };
    }
    ```

    We'll fill in those functions next, but let's take a look at this code:

    * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the *orchestrator* of individual **steps**.
    * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long.

    ## Create Your Workflow Steps

    Let's now define those missing functions.

    ```typescript title="workflows/user-signup.ts" lineNumbers
    import { FatalError } from "workflow";

    // Our workflow function defined earlier

    async function createUser(email: string) {
      "use step"; // [!code highlight]

      console.log(`Creating user with email: ${email}`);

      // Full Node.js access - database calls, APIs, etc.
      return { id: crypto.randomUUID(), email };
    }

    async function sendWelcomeEmail(user: { id: string; email: string }) {
      "use step"; // [!code highlight]

      console.log(`Sending welcome email to user: ${user.id}`);

      if (Math.random() < 0.3) {
        // By default, steps will be retried for unhandled errors
        throw new Error("Retryable!");
      }
    }

    async function sendOnboardingEmail(user: { id: string; email: string }) {
      "use step"; // [!code highlight]

      if (!user.email.includes("@")) {
        // To skip retrying, throw a FatalError instead
        throw new FatalError("Invalid Email");
      }

      console.log(`Sending onboarding email to user: ${user.id}`);
    }
    ```

    Taking a look at this code:

    * Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, just like `sleep`.
    * If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count).
    * Steps can throw a `FatalError` if an error is intentional and should not be retried.

    <Callout>
      We'll dive deeper into workflows, steps, and other ways to suspend or handle
      events in [Foundations](/docs/foundations).
    </Callout>
  </Step>

  <Step>
    ## Create Your Route Handler

    To invoke your new workflow, we'll create a new API route handler at `server/api/signup.post.ts` with the following code:

    ```typescript title="server/api/signup.post.ts"
    import { start } from "workflow/api";
    import { defineEventHandler } from "nitro/h3";
    import { handleUserSignup } from "../../workflows/user-signup";

    export default defineEventHandler(async ({ req }) => {
      const { email } = await req.json() as { email: string };
      // Executes asynchronously and doesn't block your app
      await start(handleUserSignup, [email]);
      return {
        message: "User signup workflow started",
      }
    });
    ```

    This Route Handler creates a `POST` request endpoint at `/api/signup` that will trigger your workflow.

    <Callout>
      Workflows can be triggered from API routes or any server-side
      code.
    </Callout>
  </Step>

  <Step>
    ## Run in development

    To start your development server, run the following command in your terminal in the Nitro root directory:

    ```bash
    npm run dev
    ```

    Once your development server is running, you can trigger your workflow by running this command in the terminal:

    ```bash
    curl -X POST --json '{"email":"hello@example.com"}' http://localhost:3000/api/signup
    ```

    Check the Nitro development server logs to see your workflow execute as well as the steps that are being processed.

    Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.

    ```bash
    # Open the observability Web UI
    npx workflow web
    # or if you prefer a terminal interface, use the CLI inspect command
    npx workflow inspect runs
    ```

        <img alt="Workflow SDK Web UI" src={__img0} placeholder="blur" />
  </Step>
</Steps>

## Deploying to production

Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration.

<FluidComputeCallout />

Check the [Deploying](/docs/deploying) section to learn how your workflows can be deployed elsewhere.

## Troubleshooting

### `start()` says it received an invalid workflow function

If you see this error:

```
'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.
```

Check both of these first:

1. The workflow function includes `"use workflow"`.
2. Your Nitro config includes the `workflow/nitro` module.

See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes.

## Next Steps

* Learn more about the [Foundations](/docs/foundations).
* Check [Errors](/docs/errors) if you encounter issues.
* Explore the [API Reference](/docs/api-reference).


---
title: Nuxt
description: This guide will walk through setting up your first workflow in a Nuxt app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects.
type: guide
summary: Set up Workflow SDK in a Nuxt app.
prerequisites:
  - /docs/getting-started
related:
  - /docs/foundations/workflows-and-steps
---

# Nuxt





<Steps>
  <Step>
    ## Create Your Nuxt Project

    Start by creating a new Nuxt project. This command will create a new directory named `nuxt-app` and setup a Nuxt project inside it.

    ```bash
    npm create nuxt@latest nuxt-app
    ```

    Enter the newly made directory:

    ```bash
    cd nuxt-app
    ```

    ### Install `workflow`

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add workflow
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    ### Configure Nuxt

    Add `workflow` to your `nuxt.config.ts`. This automatically configures the Nitro integration and enables usage of the `"use workflow"` and `"use step"` directives.

    ```typescript title="nuxt.config.ts" lineNumbers
    import { defineNuxtConfig } from "nuxt/config";

    export default defineNuxtConfig({
      modules: ["workflow/nuxt"], // [!code highlight]
      compatibilityDate: "latest",
    });
    ```

    This will also automatically enable the TypeScript plugin, which provides helpful IntelliSense hints in your IDE for workflow and step functions.

    <Accordion type="single" collapsible>
      <AccordionItem value="typescript-intellisense" className="[&_h3]:my-0">
        <AccordionTrigger className="[&_p]:my-0 text-lg [&_p]:text-foreground">
          Disable TypeScript Plugin (Optional)
        </AccordionTrigger>

        <AccordionContent className="[&_p]:my-2">
          The TypeScript plugin is enabled by default. If you need to disable it, you can configure it in your `nuxt.config.ts`:

          {/* @skip-typecheck: incomplete code sample */}

          ```typescript title="nuxt.config.ts" lineNumbers
          export default defineNuxtConfig({
            modules: ["workflow/nuxt"],
            workflow: {
              typescriptPlugin: false, // [!code highlight]
            },
            compatibilityDate: "latest",
          });
          ```
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  </Step>

  <Step>
    ## Create Your First Workflow

    Create a new file for our first workflow:

    ```typescript title="server/workflows/user-signup.ts" lineNumbers
    import { sleep } from "workflow";

    export async function handleUserSignup(email: string) {
      "use workflow"; // [!code highlight]

      const user = await createUser(email);
      await sendWelcomeEmail(user);

      await sleep("5s"); // Pause for 5s - doesn't consume any resources
      await sendOnboardingEmail(user);

      console.log("Workflow is complete! Run 'npx workflow web' to inspect your run")

      return { userId: user.id, status: "onboarded" };
    }
    ```

    We'll fill in those functions next, but let's take a look at this code:

    * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the *orchestrator* of individual **steps**.
    * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long.

    ## Create Your Workflow Steps

    Let's now define those missing functions.

    ```typescript title="server/workflows/user-signup.ts" lineNumbers
    import { FatalError } from "workflow";

    // Our workflow function defined earlier

    async function createUser(email: string) {
      "use step"; // [!code highlight]

      console.log(`Creating user with email: ${email}`);

      // Full Node.js access - database calls, APIs, etc.
      return { id: crypto.randomUUID(), email };
    }

    async function sendWelcomeEmail(user: { id: string; email: string }) {
      "use step"; // [!code highlight]

      console.log(`Sending welcome email to user: ${user.id}`);

      if (Math.random() < 0.3) {
        // By default, steps will be retried for unhandled errors
        throw new Error("Retryable!");
      }
    }

    async function sendOnboardingEmail(user: { id: string; email: string }) {
      "use step"; // [!code highlight]

      if (!user.email.includes("@")) {
        // To skip retrying, throw a FatalError instead
        throw new FatalError("Invalid Email");
      }

      console.log(`Sending onboarding email to user: ${user.id}`);
    }
    ```

    Taking a look at this code:

    * Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, just like `sleep`.
    * If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count).
    * Steps can throw a `FatalError` if an error is intentional and should not be retried.

    <Callout>
      We'll dive deeper into workflows, steps, and other ways to suspend or handle
      events in [Foundations](/docs/foundations).
    </Callout>
  </Step>

  <Step>
    ## Create Your API Route

    To invoke your new workflow, we'll create a new API route handler at `server/api/signup.post.ts` with the following code:

    ```typescript title="server/api/signup.post.ts"
    import { start } from "workflow/api";
    import { defineEventHandler, readBody } from "h3";
    import { handleUserSignup } from "../workflows/user-signup";

    export default defineEventHandler(async (event) => {
      const { email } = await readBody(event);

      // Executes asynchronously and doesn't block your app
      await start(handleUserSignup, [email]);

      return {
        message: "User signup workflow started",
      };
    });
    ```

    This API route creates a `POST` request endpoint at `/api/signup` that will trigger your workflow.

    <Callout>
      Workflows can be triggered from API routes or any server-side
      code.
    </Callout>
  </Step>

  <Step>
    ## Run in development

    To start your development server, run the following command in your terminal in the Nuxt root directory:

    ```bash
    npm run dev
    ```

    Once your development server is running, you can trigger your workflow by running this command in the terminal:

    ```bash
    curl -X POST --json '{"email":"hello@example.com"}' http://localhost:3000/api/signup
    ```

    Check the Nuxt development server logs to see your workflow execute as well as the steps that are being processed.

    Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.

    ```bash
    # Open the observability Web UI
    npx workflow web
    # or if you prefer a terminal interface, use the CLI inspect command
    npx workflow inspect runs
    ```

        <img alt="Workflow SDK Web UI" src={__img0} placeholder="blur" />
  </Step>
</Steps>

## Deploying to production

Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration.

<FluidComputeCallout />

Check the [Deploying](/docs/deploying) section to learn how your workflows can be deployed elsewhere.

## Troubleshooting

### `start()` says it received an invalid workflow function

If you see this error:

```
'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.
```

Check both of these first:

1. The workflow function includes `"use workflow"`.
2. Your `nuxt.config.ts` includes the `workflow/nuxt` module.

See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes.

## Next Steps

* Learn more about the [Foundations](/docs/foundations).
* Check [Errors](/docs/errors) if you encounter issues.
* Explore the [API Reference](/docs/api-reference).


---
title: Python
description: Build durable workflows and AI agents in Python with the Vercel SDK.
type: guide
summary: Set up the Workflow Python SDK in your Python application.
prerequisites:
  - /docs/getting-started
related:
  - /docs/foundations
  - /docs/foundations/workflows-and-steps
---

# Python



<Callout type="warn">
  The Python SDK is currently in **beta**. APIs and behavior may change. For the latest documentation and updates, see the [official Vercel Workflow Python documentation](https://vercel.com/docs/workflow/python?language=py).
</Callout>

You can build durable workflows in Python using the [`vercel` Python SDK](https://pypi.org/project/vercel/). Your workflow code can pause, resume, and maintain state, just like the JavaScript and TypeScript Workflow SDK.

## Getting Started

Install the `vercel` package:

```bash filename="Terminal"
pip install vercel
```

Configure `experimentalServices` in your `vercel.json`:

```json filename="vercel.json"
{
  "experimentalServices": {
    "ai_content_workflow": {
      "type": "worker",
      "entrypoint": "app/workflows/ai_content_workflow.py",
      "topics": ["__wkf_*"]
    }
  }
}
```

## Workflows

A workflow is a stateful function that coordinates multi-step logic over time. Create a `Workflows` instance and use the `@wf.workflow` decorator to mark a function as durable:

```python filename="app/workflow.py" {3}
from vercel import workflow

wf = workflow.Workflows()
```

```python filename="app/workflows/ai_content_workflow.py" {3}
from app.workflow import wf

@wf.workflow
async def ai_content_workflow(*, topic: str):
    draft = await generate_draft(topic=topic)
    summary = await summarize_draft(draft=draft)

    return {
        "draft": draft,
        "summary": summary,
    }
```

Under the hood, the workflow compiles into a route that orchestrates execution. All inputs and outputs are recorded in an event log. If a deploy or crash happens, the system replays execution deterministically from where it stopped.

## Steps

A step is a stateless function that runs a unit of durable work inside a workflow. Use `@wf.step` to mark a function as a step:

```python filename="app/steps/generate_draft.py" {4,8}
import random
from app.workflow import wf

@wf.step
async def generate_draft(*, topic: str):
    return await ai_generate(prompt=f"Write a blog post about {topic}")

@wf.step
async def summarize_draft(*, draft: str):
    summary = await ai_summarize(text=draft)

    # Simulate a transient error. The step automatically retries.
    if random.random() < 0.3:
        raise Exception("Transient AI provider error")

    return summary
```

Each step compiles into an isolated route. While the step executes, the workflow suspends without consuming resources. When the step completes, the workflow resumes automatically where it left off.

## Sleep

Sleep pauses a workflow for a specified duration without consuming compute resources:

```python filename="app/workflows/ai_refine.py" {7}
from vercel import workflow

@wf.workflow
async def ai_refine_workflow(*, draft_id: str):
    draft = await fetch_draft(draft_id)

    await workflow.sleep("7 days")  # Wait 7 days to gather more signals.

    refined = await refine_draft(draft)

    return {
        "draft_id": draft_id,
        "refined": refined,
    }
```

The sleep call pauses the workflow and consumes no resources. The workflow resumes automatically when the time expires.

## Hooks

A hook lets a workflow wait for external events such as user actions, webhooks, or third-party API responses.

Define a hook model with Pydantic and `workflow.BaseHook`:

```python filename="app/workflows/approval.py" {3,14}
from vercel import workflow

class Approval(BaseModel, workflow.BaseHook):
    """Human approval for AI-generated drafts"""

    decision: Literal["approved", "changes"]
    notes: str | None = None

@wf.workflow
async def ai_approval_workflow(*, topic: str):
    draft = await generate_draft(topic=topic)

    # Wait for human approval events
    async for event in Approval.wait(token="draft-123"):
        if event.decision == "approved":
            await publish_draft(draft)
            break

        revised = await refine_draft(draft, event.notes)
        await publish_draft(revised)
```

Resume the workflow when data arrives:

```python filename="app/api/resume.py" {5}
@app.post("/api/resume")
async def resume(approval: Approval):
    """Resume the workflow when an approval is received"""

    await approval.resume("draft-123")
    return {"ok": True}
```

When a hook receives data, the workflow resumes automatically. You don't need polling, message queues, or manual state management.

## Learn More

For comprehensive documentation, examples, and the latest updates, visit the [official Vercel Workflow Python documentation](https://vercel.com/docs/workflow/python).

## Next Steps

* Learn more about the [Foundations](/docs/foundations).
* Check [Errors](/docs/errors) if you encounter issues.
* Explore the [API Reference](/docs/api-reference).


---
title: SvelteKit
description: This guide will walk through setting up your first workflow in a SvelteKit app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects.
type: guide
summary: Set up Workflow SDK in a SvelteKit app.
prerequisites:
  - /docs/getting-started
related:
  - /docs/foundations/workflows-and-steps
---

# SvelteKit





<Steps>
  <Step>
    ## Create Your SvelteKit Project

    Start by creating a new SvelteKit project. This command will create a new directory named `my-workflow-app` with a minimal setup and setup a SvelteKit project inside it.

    ```bash
    npx sv create my-workflow-app --template=minimal --types=ts --no-add-ons
    ```

    Enter the newly made directory:

    ```bash
    cd my-workflow-app
    ```

    ### Install `workflow`

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add workflow
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    ### Configure Vite

    Add `workflowPlugin()` to your Vite config. This enables usage of the `"use workflow"` and `"use step"` directives.

    ```typescript title="vite.config.ts" lineNumbers
    import { sveltekit } from "@sveltejs/kit/vite";
    import { defineConfig } from "vite";
    import { workflowPlugin } from "workflow/sveltekit"; // [!code highlight]

    export default defineConfig({
      plugins: [sveltekit(), workflowPlugin()], // [!code highlight]
    });
    ```

    `workflowPlugin()` accepts an options object:

    | Option      | Type                                                      | Default                           | Description                                                                                                                                                                                                                                                                                                                                                                  |
    | ----------- | --------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles — helps stay under the Vercel 250MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. |

    <Accordion type="single" collapsible>
      <AccordionItem value="typescript-intellisense" className="[&_h3]:my-0">
        <AccordionTrigger className="text-sm">
          ### Setup IntelliSense for TypeScript (Optional)
        </AccordionTrigger>

        <AccordionContent className="[&_p]:my-2">
          To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`:

          ```json title="tsconfig.json" lineNumbers
          {
            "compilerOptions": {
              // ... rest of your TypeScript config
              "plugins": [
                {
                  "name": "workflow" // [!code highlight]
                }
              ]
            }
          }
          ```
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  </Step>

  <Step>
    ## Create Your First Workflow

    Create a new file for our first workflow:

    ```typescript title="workflows/user-signup.ts" lineNumbers
    import { sleep } from "workflow";

    export async function handleUserSignup(email: string) {
      "use workflow"; // [!code highlight]

      const user = await createUser(email);
      await sendWelcomeEmail(user);

      await sleep("5s"); // Pause for 5s - doesn't consume any resources
      await sendOnboardingEmail(user);

      console.log("Workflow is complete! Run 'npx workflow web' to inspect your run")

      return { userId: user.id, status: "onboarded" };
    }

    ```

    We'll fill in those functions next, but let's take a look at this code:

    * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the *orchestrator* of individual **steps**.
    * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long.

    ## Create Your Workflow Steps

    Let's now define those missing functions.

    ```typescript title="workflows/user-signup.ts" lineNumbers
    import { FatalError } from "workflow"

    // Our workflow function defined earlier

    async function createUser(email: string) {
      "use step"; // [!code highlight]

      console.log(`Creating user with email: ${email}`);

      // Full Node.js access - database calls, APIs, etc.
      return { id: crypto.randomUUID(), email };
    }

    async function sendWelcomeEmail(user: { id: string; email: string; }) {
      "use step"; // [!code highlight]

      console.log(`Sending welcome email to user: ${user.id}`);

      if (Math.random() < 0.3) {
      // By default, steps will be retried for unhandled errors
       throw new Error("Retryable!");
      }
    }

    async function sendOnboardingEmail(user: { id: string; email: string}) {
      "use step"; // [!code highlight]

      if (!user.email.includes("@")) {
        // To skip retrying, throw a FatalError instead
        throw new FatalError("Invalid Email");
      }

      console.log(`Sending onboarding email to user: ${user.id}`);
    }
    ```

    Taking a look at this code:

    * Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, just like `sleep`.
    * If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count).
    * Steps can throw a `FatalError` if an error is intentional and should not be retried.

    <Callout>
      We'll dive deeper into workflows, steps, and other ways to suspend or handle events in [Foundations](/docs/foundations).
    </Callout>
  </Step>

  <Step>
    ## Create Your Route Handler

    To invoke your new workflow, we'll have to add your workflow to a `POST` API route handler, `src/routes/api/signup/+server.ts` with the following code:

    ```typescript title="src/routes/api/signup/+server.ts"
    import { start } from "workflow/api";
    import { handleUserSignup } from "../../../../workflows/user-signup";
    import { json, type RequestHandler } from "@sveltejs/kit";

    export const POST: RequestHandler = async ({
      request,
    }: {
      request: Request;
    }) => {
      const { email } = await request.json();

      // Executes asynchronously and doesn't block your app
      await start(handleUserSignup, [email]);

      return json({ message: "User signup workflow started" });
    };

    ```

    This route handler creates a `POST` request endpoint at `/api/signup` that will trigger your workflow.

    <Callout>
      Workflows can be triggered from API routes or any server-side code.
    </Callout>
  </Step>
</Steps>

## Run in development

To start your development server, run the following command in your terminal in the SvelteKit root directory:

```bash
npm run dev
```

Once your development server is running, you can trigger your workflow by running this command in the terminal:

```bash
curl -X POST --json '{"email":"hello@example.com"}' http://localhost:5173/api/signup
```

Check the SvelteKit development server logs to see your workflow execute as well as the steps that are being processed.

Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.

```bash
# Open the observability Web UI
npx workflow web
# or if you prefer a terminal interface, use the CLI inspect command
npx workflow inspect runs
```

<img alt="Workflow SDK Web UI" src={__img0} placeholder="blur" />

## Deploying to production

Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration.

<FluidComputeCallout />

Check the [Deploying](/docs/deploying) section to learn how your workflows can be deployed elsewhere.

## Troubleshooting

### `start()` says it received an invalid workflow function

If you see this error:

```
'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.
```

Check both of these first:

1. The workflow function includes `"use workflow"`.
2. Your `vite.config.ts` includes the `workflow/sveltekit` plugin.

See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes.

## Next Steps

* Learn more about the [Foundations](/docs/foundations).
* Check [Errors](/docs/errors) if you encounter issues.
* Explore the [API Reference](/docs/api-reference).


---
title: TanStack Start
description: Set up your first durable workflow in a TanStack Start application.
type: guide
summary: Set up Workflow SDK in a TanStack Start app.
prerequisites:
  - /docs/getting-started
related:
  - /docs/foundations/workflows-and-steps
---

# TanStack Start





This guide will walk through setting up your first workflow in a TanStack Start app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects.

***

<Steps>
  <Step>
    ## Create Your TanStack Start Project

    Start by creating a new TanStack Start project:

    ```bash
    npx @tanstack/cli create my-workflow-app
    ```

    Enter the newly made directory:

    ```bash
    cd my-workflow-app
    ```

    ### Install `workflow`

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add workflow
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add workflow
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    ### Configure TanStack Start

    TanStack Start runs on Vite, so the Workflow SDK is wired in via the same `workflow/vite` plugin. Add `workflow()` to the existing `plugins` array in your Vite config — list it first so the `"use workflow"` and `"use step"` transforms run before any other plugin processes the file.

    ```typescript title="vite.config.ts" lineNumbers
    import { defineConfig } from "vite";
    import { workflow } from "workflow/vite";
    // ...

    export default defineConfig({
      plugins: [
        workflow(), // [!code highlight]
        // ...the existing tanstackStart(), nitro(), and any other plugins
      ],
    });
    ```

    <Accordion type="single" collapsible>
      <AccordionItem value="typescript-intellisense" className="[&_h3]:my-0">
        <AccordionTrigger className="text-sm">
          ### Setup IntelliSense for TypeScript (Optional)
        </AccordionTrigger>

        <AccordionContent className="[&_p]:my-2">
          To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`:

          ```json title="tsconfig.json" lineNumbers
          {
            "compilerOptions": {
              // ... rest of your TypeScript config
              "plugins": [
                {
                  "name": "workflow" // [!code highlight]
                }
              ]
            }
          }
          ```
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  </Step>

  <Step>
    ## Create Your First Workflow

    Create a new file for our first workflow:

    ```typescript title="src/workflows/user-signup.ts" lineNumbers
    import { sleep } from "workflow";

    export async function handleUserSignup(email: string) {
      "use workflow"; // [!code highlight]

      const user = await createUser(email);
      await sendWelcomeEmail(user);

      await sleep("5s"); // Pause for 5s - doesn't consume any resources
      await sendOnboardingEmail(user);

      return { userId: user.id, status: "onboarded" };
    }
    ```

    We'll fill in those functions next, but let's take a look at this code:

    * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the *orchestrator* of individual **steps**.
    * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long.

    ## Create Your Workflow Steps

    Let's now define those missing functions.

    ```typescript title="src/workflows/user-signup.ts" lineNumbers
    import { FatalError } from "workflow"

    // Our workflow function defined earlier

    async function createUser(email: string) {
      "use step"; // [!code highlight]

      console.log(`Creating user with email: ${email}`);

      // Full Node.js access - database calls, APIs, etc.
      return { id: crypto.randomUUID(), email };
    }

    async function sendWelcomeEmail(user: { id: string; email: string; }) {
      "use step"; // [!code highlight]

      console.log(`Sending welcome email to user: ${user.id}`);

      if (Math.random() < 0.3) {
      // By default, steps will be retried for unhandled errors
       throw new Error("Retryable!");
      }
    }

    async function sendOnboardingEmail(user: { id: string; email: string}) {
      "use step"; // [!code highlight]

      if (!user.email.includes("@")) {
        // To skip retrying, throw a FatalError instead
        throw new FatalError("Invalid Email");
      }

      console.log(`Sending onboarding email to user: ${user.id}`);
    }
    ```

    Taking a look at this code:

    * Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, just like `sleep`.
    * If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count).
    * Steps can throw a `FatalError` if an error is intentional and should not be retried.

    <Callout>
      We'll dive deeper into workflows, steps, and other ways to suspend or handle events in [Foundations](/docs/foundations).
    </Callout>
  </Step>

  <Step>
    ## Create Your Route Handler

    To invoke your new workflow, add a server handler at `src/routes/api/signup.ts`:

    ```typescript title="src/routes/api/signup.ts"
    import { createFileRoute } from "@tanstack/react-router";
    import { json } from "@tanstack/react-start";
    import { start } from "workflow/api";
    import { handleUserSignup } from "../../workflows/user-signup";

    export const Route = createFileRoute("/api/signup")({
      server: {
        handlers: {
          POST: async ({ request }) => {
            const { email } = await request.json();
            // Executes asynchronously and doesn't block your app
            await start(handleUserSignup, [email]);
            return json({ message: "User signup workflow started" });
          },
        },
      },
    });
    ```

    This route handler creates a `POST` request endpoint at `/api/signup` that will trigger your workflow.

    <Callout>
      Workflows can be triggered from API routes or any server-side code.
    </Callout>
  </Step>
</Steps>

## Run in development

To start your development server, run the following command in your terminal in the TanStack Start root directory:

```bash
npm run dev
```

Once your development server is running, you can trigger your workflow by running this command in the terminal:

```bash
curl -X POST --json '{"email":"hello@example.com"}' http://localhost:3000/api/signup
```

Check the dev server logs to see your workflow execute as well as the steps that are being processed.

Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.

```bash
# Open the observability Web UI on http://localhost:3456
npx workflow web
# or if you prefer a terminal interface, use the CLI inspect command
npx workflow inspect runs
```

<img alt="Workflow SDK Web UI" src={__img0} placeholder="blur" />

***

## Deploying to production

Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration.

<FluidComputeCallout />

Check the [Deploying](/docs/deploying) section to learn how your workflows can be deployed elsewhere.

## Next Steps

* Learn more about the [Foundations](/docs/foundations).
* Check [Errors](/docs/errors) if you encounter issues.
* Explore the [API Reference](/docs/api-reference).


---
title: Vite
description: Set up your first durable workflow in a Vite application.
type: guide
summary: Set up Workflow SDK in a Vite app.
prerequisites:
  - /docs/getting-started
related:
  - /docs/foundations/workflows-and-steps
---

# Vite





This guide will walk through setting up your first workflow in a Vite app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects.

***

<Steps>
  <Step>
    ## Create Your Vite Project

    Start by creating a new Vite project. This command will create a new directory named `my-workflow-app` with a minimal setup and setup a Vite project inside it.

    ```bash
    npm create vite@latest my-workflow-app -- --template react-ts
    ```

    Enter the newly made directory:

    ```bash
    cd my-workflow-app
    ```

    ### Install `workflow` and `nitro`

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i workflow nitro
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add workflow nitro
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add workflow nitro
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add workflow nitro
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    <Callout>
      While Vite provides the build tooling and development server, Nitro adds the server framework needed for API routes and deployment. Together they enable building full-stack applications with workflow support. Learn more about Nitro [here](https://v3.nitro.build).
    </Callout>

    ### Configure Vite

    Add `workflow()` to your Vite config. This enables usage of the `"use workflow"` and `"use step"` directives.

    ```typescript title="vite.config.ts" lineNumbers
    import { nitro } from "nitro/vite";
    import { defineConfig } from "vite";
    import { workflow } from "workflow/vite";

    export default defineConfig({
      plugins: [nitro(), workflow()], // [!code highlight]
      nitro: { // [!code highlight]
        serverDir: "./", // [!code highlight]
      }, // [!code highlight]
    });
    ```

    <Accordion type="single" collapsible>
      <AccordionItem value="typescript-intellisense" className="[&_h3]:my-0">
        <AccordionTrigger className="text-sm">
          ### Setup IntelliSense for TypeScript (Optional)
        </AccordionTrigger>

        <AccordionContent className="[&_p]:my-2">
          To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`:

          ```json title="tsconfig.json" lineNumbers
          {
            "compilerOptions": {
              // ... rest of your TypeScript config
              "plugins": [
                {
                  "name": "workflow" // [!code highlight]
                }
              ]
            }
          }
          ```
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  </Step>

  <Step>
    ## Create Your First Workflow

    Create a new file for our first workflow:

    ```typescript title="workflows/user-signup.ts" lineNumbers
    import { sleep } from "workflow";

    export async function handleUserSignup(email: string) {
      "use workflow"; // [!code highlight]

      const user = await createUser(email);
      await sendWelcomeEmail(user);

      await sleep("5s"); // Pause for 5s - doesn't consume any resources
      await sendOnboardingEmail(user);

      return { userId: user.id, status: "onboarded" };
    }

    ```

    We'll fill in those functions next, but let's take a look at this code:

    * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the *orchestrator* of individual **steps**.
    * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long.

    ## Create Your Workflow Steps

    Let's now define those missing functions.

    ```typescript title="workflows/user-signup.ts" lineNumbers
    import { FatalError } from "workflow"

    // Our workflow function defined earlier

    async function createUser(email: string) {
      "use step"; // [!code highlight]

      console.log(`Creating user with email: ${email}`);

      // Full Node.js access - database calls, APIs, etc.
      return { id: crypto.randomUUID(), email };
    }

    async function sendWelcomeEmail(user: { id: string; email: string; }) {
      "use step"; // [!code highlight]

      console.log(`Sending welcome email to user: ${user.id}`);

      if (Math.random() < 0.3) {
      // By default, steps will be retried for unhandled errors
       throw new Error("Retryable!");
      }
    }

    async function sendOnboardingEmail(user: { id: string; email: string}) {
      "use step"; // [!code highlight]

      if (!user.email.includes("@")) {
        // To skip retrying, throw a FatalError instead
        throw new FatalError("Invalid Email");
      }

      console.log(`Sending onboarding email to user: ${user.id}`);
    }
    ```

    Taking a look at this code:

    * Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, just like `sleep`.
    * If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count).
    * Steps can throw a `FatalError` if an error is intentional and should not be retried.

    <Callout>
      We'll dive deeper into workflows, steps, and other ways to suspend or handle events in [Foundations](/docs/foundations).
    </Callout>
  </Step>

  <Step>
    ## Create Your Route Handler

    To invoke your new workflow, we'll have to add your workflow to a `POST` API route handler, `api/signup.post.ts` with the following code:

    ```typescript title="api/signup.post.ts"
    import { start } from "workflow/api";
    import { defineEventHandler } from "nitro/h3";
    import { handleUserSignup } from "../workflows/user-signup";

    export default defineEventHandler(async ({ req }) => {
      const { email } = await req.json() as { email: string };
      // Executes asynchronously and doesn't block your app
      await start(handleUserSignup, [email]);
      return {
        message: "User signup workflow started",
      }
    });
    ```

    This route handler creates a `POST` request endpoint at `/api/signup` that will trigger your workflow.

    <Callout>
      Workflows can be triggered from API routes or any server-side code.
    </Callout>
  </Step>
</Steps>

## Run in development

To start your development server, run the following command in your terminal in the Vite root directory:

```bash
npm run dev
```

Once your development server is running, you can trigger your workflow by running this command in the terminal:

```bash
curl -X POST --json '{"email":"hello@example.com"}' http://localhost:3000/api/signup
```

Check the Vite development server logs to see your workflow execute as well as the steps that are being processed.

Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.

```bash
# Open the observability Web UI
npx workflow web
# or if you prefer a terminal interface, use the CLI inspect command
npx workflow inspect runs
```

<img alt="Workflow SDK Web UI" src={__img0} placeholder="blur" />

***

## Deploying to production

Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration.

<FluidComputeCallout />

Check the [Deploying](/docs/deploying) section to learn how your workflows can be deployed elsewhere.

## Troubleshooting

### `start()` says it received an invalid workflow function

If you see this error:

```
'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.
```

Check both of these first:

1. The workflow function includes `"use workflow"`.
2. Your `vite.config.ts` includes the `workflow/vite` plugin.

See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes.

## Next Steps

* Learn more about the [Foundations](/docs/foundations).
* Check [Errors](/docs/errors) if you encounter issues.
* Explore the [API Reference](/docs/api-reference).


---
title: How the Directives Work
description: Deep dive into the internals of how Workflow SDK directives transform your code.
type: conceptual
summary: Learn how the compiler transforms directive-annotated code into three execution modes.
prerequisites:
  - /docs/how-it-works/understanding-directives
related:
  - /docs/foundations/workflows-and-steps
---

# How the Directives Work



<Callout>
  This is an advanced guide that dives into internals of the Workflow SDK directive and is not required reading to use workflows. To simply use the Workflow SDK, check out the [getting started](/docs/getting-started) guides for your framework.
</Callout>

Workflows use special directives to mark code for transformation by the Workflow SDK compiler. This page explains how `"use workflow"` and `"use step"` directives work, what transformations are applied, and why they're necessary for durable execution.

## Directives Overview

Workflows use two directives to mark functions for special handling:

{/* @skip-typecheck: incomplete code sample */}

```typescript
export async function handleUserSignup(email: string) {
  "use workflow"; // [!code highlight]

  const user = await createUser(email);
  await sendWelcomeEmail(user);

  return { userId: user.id };
}

async function createUser(email: string) {
  "use step"; // [!code highlight]

  return { id: crypto.randomUUID(), email };
}
```

**Key directives:**

* `"use workflow"`: Marks a function as a durable workflow entry point
* `"use step"`: Marks a function as an atomic, retryable step

These directives trigger the `@workflow/swc-plugin` compiler to transform your code in different ways depending on the execution context.

## The Three Transformation Modes

The compiler operates in three distinct modes, transforming the same source code differently for each execution context:

<Mermaid
  chart="flowchart LR
    A[&#x22;Source Code<br/>with directives&#x22;] --> B[&#x22;Step Mode&#x22;]
    A --> C[&#x22;Workflow Mode&#x22;]
    A --> D[&#x22;Client Mode&#x22;]
    B --> E[&#x22;step.js<br/>(Step Execution)&#x22;]
    C --> F[&#x22;flow.js<br/>(Workflow Execution)&#x22;]
    D --> G[&#x22;Your App Code<br/>(Enables `start`)&#x22;]"
/>

### Comparison Table

| Mode     | Used In       | Purpose                                    | Output API Route               | Required?  |
| -------- | ------------- | ------------------------------------------ | ------------------------------ | ---------- |
| Step     | Build time    | Bundles step handlers                      | `.well-known/workflow/v1/step` | Yes        |
| Workflow | Build time    | Bundles workflow orchestrators             | `.well-known/workflow/v1/flow` | Yes        |
| Client   | Build/Runtime | Provides workflow IDs and types to `start` | Your application code          | Optional\* |

\* Client mode is **recommended** for better developer experience—it provides automatic ID generation and type safety. Without it, you must manually construct workflow IDs or use the build manifest.

## Detailed Transformation Examples

<Tabs items={["Step Mode", "Workflow Mode", "Client Mode"]}>
  <Tab value="Step Mode">
    **Step Mode** creates the step execution bundle served at `/.well-known/workflow/v1/step`.

    **Input:**

    {/* @skip-typecheck: incomplete code sample */}

    ```typescript
    export async function createUser(email: string) {
      "use step";
      return { id: crypto.randomUUID(), email };
    }
    ```

    **Output:**

    {/* @skip-typecheck: incomplete code sample */}

    ```typescript
    export async function createUser(email: string) {
      return { id: crypto.randomUUID(), email };
    }
    (function(__wf_fn, __wf_id) { // [!code highlight]
        var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map()); // [!code highlight]
        __wf_reg.set(__wf_id, __wf_fn); // [!code highlight]
        __wf_fn.stepId = __wf_id; // [!code highlight]
    })(createUser, "step//workflows/user.js//createUser"); // [!code highlight]
    ```

    **What happens:**

    * The `"use step"` directive is removed
    * The function body is kept completely intact (no transformation)
    * The function is registered with the runtime via an inline IIFE (no imports needed)
    * Step functions run with full Node.js/Deno/Bun access

    **Why no transformation?** Step functions execute in your main runtime with full access to Node.js APIs, file system, databases, etc. They don't need any special handling—they just run normally.

    **ID Format:** Step IDs follow the pattern `step//{filepath}//{functionName}`, where the filepath is relative to your project root.
  </Tab>

  <Tab value="Workflow Mode">
    **Workflow Mode** creates the workflow execution bundle served at `/.well-known/workflow/v1/flow`.

    **Input:**

    {/* @skip-typecheck: incomplete code sample */}

    ```typescript
    export async function createUser(email: string) {
      "use step";
      return { id: crypto.randomUUID(), email };
    }

    export async function handleUserSignup(email: string) {
      "use workflow";
      const user = await createUser(email);
      return { userId: user.id };
    }
    ```

    **Output:**

    {/* @skip-typecheck: incomplete code sample */}

    ```typescript
    export async function createUser(email: string) {
      return globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//workflows/user.js//createUser")(email); // [!code highlight]
    }

    export async function handleUserSignup(email: string) {
      const user = await createUser(email);
      return { userId: user.id };
    }
    handleUserSignup.workflowId = "workflow//workflows/user.js//handleUserSignup"; // [!code highlight]
    ```

    **What happens:**

    * Step function bodies are **replaced** with calls to `globalThis[Symbol.for("WORKFLOW_USE_STEP")]`
    * Workflow function bodies remain **intact**—they execute deterministically during replay
    * The workflow function gets a `workflowId` property for runtime identification
    * The `"use workflow"` directive is removed

    **Why this transformation?** When a workflow executes, it needs to replay past steps from the [event log](/docs/how-it-works/event-sourcing) rather than re-executing them. The `WORKFLOW_USE_STEP` symbol is a special runtime hook that:

    1. Checks if the step has already been executed (in the event log)
    2. If yes: Returns the cached result
    3. If no: Triggers a suspension and enqueues the step for background execution

    **ID Format:** Workflow IDs follow the pattern `workflow//{filepath}//{functionName}`. The `workflowId` property is attached to the function to allow [`start()`](/docs/api-reference/workflow-api/start) to work at runtime.
  </Tab>

  <Tab value="Client Mode">
    **Client Mode** transforms workflow functions in your application code to prevent direct execution.

    **Input:**

    {/* @skip-typecheck: incomplete code sample */}

    ```typescript
    export async function handleUserSignup(email: string) {
      "use workflow";
      const user = await createUser(email);
      return { userId: user.id };
    }
    ```

    **Output:**

    {/* @skip-typecheck: incomplete code sample */}

    ```typescript
    export async function handleUserSignup(email: string) {
      throw new Error("You attempted to execute ..."); // [!code highlight]
    }
    handleUserSignup.workflowId = "workflow//workflows/user.js//handleUserSignup"; // [!code highlight]
    ```

    **What happens:**

    * Workflow function bodies are **replaced** with an error throw
    * The `workflowId` property is added (same as workflow mode)
    * Step functions are not transformed in client mode

    **Why this transformation?** Workflow functions cannot be called directly—they must be started using [`start()`](/docs/api-reference/workflow-api/start). The error prevents accidental direct execution while the `workflowId` property allows the `start()` function to identify which workflow to launch.

    The IDs are generated exactly like in workflow mode to ensure they can be directly referenced at runtime.

    <Callout type="info">
      **Client mode is optional:** While recommended for better developer experience (automatic IDs and type safety), you can skip client mode and instead:

      * Manually construct workflow IDs using the pattern `workflow//{filepath}//{functionName}`
      * Use the workflow manifest file generated during build to lookup IDs
      * Pass IDs directly to `start()` as strings

      All framework integrations include client mode as a loader by default.
    </Callout>
  </Tab>
</Tabs>

## Generated Files

When you build your application, the Workflow SDK generates three handler files in `.well-known/workflow/v1/`:

### `flow.js`

Contains all workflow functions transformed in **workflow mode**. This file is imported by your framework to handle workflow execution requests at `POST /.well-known/workflow/v1/flow`.

**How it's structured:**

All workflow code is bundled together and embedded as a string inside `flow.js`. When a workflow needs to execute, this bundled code is run inside a **Node.js VM** (virtual machine) to ensure:

* **Determinism**: The same inputs always produce the same outputs
* **Side-effect prevention**: Direct access to Node.js APIs, file system, network, etc. is blocked
* **Sandboxed execution**: Workflow orchestration logic is isolated from the main runtime

**Build-time validation:**

The workflow mode transformation validates your code during the build:

* Catches invalid Node.js API usage (like `fs`, `http`, `child_process`)
* Prevents imports of modules that would break determinism

Most invalid patterns cause **build-time errors**, catching issues before deployment.

**What it does:**

* Exports a `POST` handler that accepts Web standard `Request` objects
* Executes bundled workflow code inside a Node.js VM for each request
* Handles workflow execution, replay, and resumption
* Returns execution results to the orchestration layer

<Callout type="info">
  **Why a VM?** Workflow functions must be deterministic to support replay. The VM sandbox prevents accidental use of non-deterministic APIs or side effects. All side effects should be performed in [step functions](/docs/foundations/workflows-and-steps#step-functions) instead.
</Callout>

### `step.js`

Contains all step functions transformed in **step mode**. This file is imported by your framework to handle step execution requests at `POST /.well-known/workflow/v1/step`.

**What it does:**

* Exports a `POST` handler that accepts Web standard `Request` objects
* Executes individual steps with full runtime access
* Returns step results to the orchestration layer

### `webhook.js`

Contains webhook handling logic for delivering external data to running workflows via [`createWebhook()`](/docs/api-reference/workflow/create-webhook).

**What it does:**

* Exports a `POST` handler that accepts webhook payloads
* Validates tokens and routes data to the correct workflow run
* Resumes workflow execution after webhook delivery

**Note:** The webhook file structure varies by framework. Next.js generates `webhook/[token]/route.js` to leverage App Router's dynamic routing, while other frameworks generate a single `webhook.js` or `webhook.mjs` handler.

## Why Three Modes?

The multi-mode transformation enables the Workflow SDK's durable execution model:

1. **Step Mode** (required) - Bundles executable step functions that can access the full runtime
2. **Workflow Mode** (required) - Creates orchestration logic that can replay from event logs
3. **Client Mode** (optional) - Prevents direct execution and enables type-safe workflow references

This separation allows:

* **Deterministic replay**: Workflows can be safely replayed from event logs without re-executing side effects
* **Sandboxed orchestration**: Workflow logic runs in a controlled VM without direct runtime access
* **Stateless execution**: Your compute can scale to zero and resume from any point in the workflow
* **Type safety**: TypeScript works seamlessly with workflow references (when using client mode)

## Determinism and Replay

A key aspect of the transformation is maintaining **deterministic replay** for workflow functions.

**Workflow functions must be deterministic:**

* Same inputs always produce the same outputs
* No direct side effects (no API calls, no database writes, no file I/O)
* Can use seeded random/time APIs provided by the VM (`Math.random()`, `Date.now()`, etc.)

Because workflow functions are deterministic and have no side effects, they can be safely re-run multiple times to calculate what the next step should be. This is why workflow function bodies remain intact in workflow mode—they're pure orchestration logic.

**Step functions can be non-deterministic:**

* Can make API calls, database queries, etc.
* Have full access to Node.js runtime and APIs
* Results are cached in the [event log](/docs/how-it-works/event-sourcing) after first execution

Learn more about [Workflows and Steps](/docs/foundations/workflows-and-steps).

## ID Generation

The compiler generates stable IDs for workflows and steps based on file paths and function names:

**Pattern:** `{type}//{filepath}//{functionName}`

**Examples:**

* `workflow//workflows/user-signup.js//handleUserSignup`
* `step//workflows/user-signup.js//createUser`
* `step//workflows/payments/checkout.ts//processPayment`

**Key properties:**

* **Stable**: IDs don't change unless you rename files or functions
* **Unique**: Each workflow/step has a unique identifier
* **Portable**: Works across different runtimes and deployments

<Callout type="info">
  Although IDs can change when files are moved or functions are renamed, Workflow SDK functions assume [atomic versioning](/docs/foundations/versioning) in the World. This means changing IDs won't break old workflows from running, but will prevent runs from being upgraded and will cause your workflow/step names to change in observability across deployments.
</Callout>

## Framework Integration

These transformations are framework-agnostic—they output standard JavaScript that works anywhere.

**For users**: Your framework handles all transformations automatically. See the [Getting Started](/docs/getting-started) guide for your framework.

**For framework authors**: Learn how to integrate these transformations into your framework in [Building Framework Integrations](/docs/how-it-works/framework-integrations).

## Debugging Transformed Code

If you need to debug transformation issues, you can inspect the generated files:

1. **Look in `.well-known/workflow/v1/`**: Check the generated `flow.js`, `step.js`,`webhook.js`, and other emitted debug files.
2. **Check build logs**: Most frameworks log transformation activity during builds
3. **Verify directives**: Ensure `"use workflow"` and `"use step"` are the first statements in functions
4. **Check file locations**: Transformations only apply to files in configured source directories


---
title: Encryption
description: Learn how Workflow SDK encrypts user data end-to-end in the event log.
type: conceptual
summary: Understand how workflow and step data is encrypted at rest.
prerequisites:
  - /docs/how-it-works/event-sourcing
related:
  - /docs/observability
  - /docs/deploying/world/vercel-world
---

# Encryption



<Callout>
  This guide explains how Workflow SDK encrypts user data in the event log. Understanding these details is not required to use workflows — encryption is automatic and requires no code changes. For getting started, see the [getting started](/docs/getting-started) guides for your framework.
</Callout>

Workflow SDK supports automatic end-to-end encryption of all user data before it is written to the event log. When a `World` implementation provides encryption support, it is safe to pass sensitive data — such as API keys, tokens, or user credentials — as workflow inputs, step arguments, and return values. The storage backend only ever sees ciphertext.

Encryption support varies by `World` implementation. See the [Worlds](/worlds) page to check which worlds support this feature. `World` implementations opt into encryption by providing a `getEncryptionKeyForRun()` method — the core runtime will use it automatically when present.

## What Is Encrypted

All user data flowing through the event log is encrypted:

* **Workflow inputs** — arguments passed when starting a workflow
* **Workflow return values** — the final output of a workflow
* **Step inputs** — arguments passed to step functions
* **Step return values** — the result returned by step functions
* **Hook metadata** — data attached when creating a hook
* **Hook payloads** — data received by hooks and webhooks
* **Stream data** — each frame in a `ReadableStream` or `WritableStream`

Metadata such as workflow names, step names, entity IDs, timestamps, and lifecycle states are **not** encrypted. This allows the observability tools to display run structure and timelines without requiring decryption.

## How It Works

### Key Management

Each workflow run is encrypted with its own unique key, provided by the `World` implementation via `getEncryptionKeyForRun()`. How the key is generated and stored is up to the `World`.

For example, the [Vercel World](/docs/deploying/world/vercel-world) provides unique keys per run and execution environment, ensuring that a given run can only decrypt data from that run itself.

### Encryption Algorithm

Data is encrypted using **AES-256-GCM** via the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API):

* A random 12-byte nonce is generated for each encryption operation
* The GCM authentication tag provides integrity verification — any tampering with the ciphertext is detected
* The same plaintext produces different ciphertext each time due to the random nonce

## Decrypting Data

When viewing workflow runs through the observability tools, encrypted fields display as locked placeholders until you explicitly choose to decrypt them.

### Permissions

Decryption access is controlled by the `World` implementation. On Vercel, decryption follows the same permissions model as project environment variables — if you don't have permission to view environment variable values for a project, you won't be able to decrypt workflow data either. Each decryption request is recorded in your [Vercel audit log](https://vercel.com/docs/audit-log), giving your team full visibility into when and by whom workflow data was accessed.

### Web Dashboard

Click the **Decrypt** button in the run detail panel to decrypt all data fields. Decryption happens entirely in the browser via the Web Crypto API — the observability server retrieves the encryption key but never sees your plaintext data.

### CLI

Add the `--decrypt` flag to any `inspect` command:

```bash
# Inspect a specific run
npx workflow inspect run <run-id> --decrypt

# Inspect a specific step
npx workflow inspect step <step-id> --run <run-id> --decrypt

# List events for a run
npx workflow inspect events --run <run-id> --decrypt

# Inspect a specific stream
npx workflow inspect stream <stream-id> --run <run-id> --decrypt
```

Without `--decrypt`, encrypted fields display as `🔒 Encrypted` placeholders.

## Custom World Implementations

The core runtime encrypts data automatically when the `World` implementation provides a `getEncryptionKeyForRun()` method. The core runtime can call this method in two forms:

{/* @skip-typecheck - interface signature, not runnable code */}

```typescript
getEncryptionKeyForRun?(run: WorkflowRun): Promise<Uint8Array | undefined>;
getEncryptionKeyForRun?(
  runId: string,
  context?: Record<string, unknown>
): Promise<Uint8Array | undefined>;
```

Use `getEncryptionKeyForRun(run)` when the run entity already exists. Use `getEncryptionKeyForRun(runId, context?)` in runtime paths like `start()` where the run has not been created yet but the world may still need context such as `deploymentId`.

To add encryption support to a custom `World`:

1. Implement `getEncryptionKeyForRun()` on your `World` class, handling both call shapes
2. Return the raw 32-byte key as a `Uint8Array` — the core runtime uses it for AES-256-GCM operations
3. Ensure the same key is returned for the same run ID across invocations (for decryption during replay)

```typescript
import type { WorkflowRun, World } from "@workflow/world";

export const getEncryptionKeyForRun: World["getEncryptionKeyForRun"] = async (
  run: WorkflowRun | string,
  context?: Record<string, unknown>
) => {
  const runId = typeof run === "string" ? run : run.runId;
  const deploymentId =
    typeof run === "string"
      ? (context?.deploymentId as string | undefined)
      : run.deploymentId;

  return await lookupRunKey(runId, deploymentId);
};

async function lookupRunKey(
  runId: string,
  deploymentId?: string
): Promise<Uint8Array | undefined> {
  // Look up or derive the encryption key for this run
  // Return undefined to skip encryption
  return new Uint8Array(32);
}
```

The [Vercel World](/docs/deploying/world/vercel-world) implementation uses HKDF derivation from a deployment-scoped key, but any consistent key management scheme will work.


---
title: Event Sourcing
description: Learn how Workflow SDK uses event sourcing internally for debugging and observability.
type: conceptual
summary: Understand the event log that powers workflow replay and debugging.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/observability
---

# Event Sourcing



<Callout>
  This guide explores how the Workflow SDK uses event sourcing internally. Understanding these concepts is helpful for debugging and building observability tools, but is not required to use workflows. For getting started with workflows, see the [getting started](/docs/getting-started) guides for your framework.
</Callout>

The Workflow SDK uses event sourcing to track all state changes in workflow executions. Every mutation creates an event that is persisted to the event log, and entity state is derived by replaying these events.

This page explains the event sourcing model and entity lifecycles.

## Event Sourcing Overview

Event sourcing is a persistence pattern where state changes are stored as a sequence of events rather than by updating records in place. The current state of any entity is reconstructed by replaying its events from the beginning.

**Benefits for durable workflows:**

* **Complete audit trail**: Every state change is recorded with its timestamp and context
* **Debugging**: Replay the exact sequence of events that led to any state
* **Consistency**: Events provide a single source of truth for all entity state
* **Recoverability**: State can be reconstructed from the event log after failures

In the Workflow SDK, the following entity types are managed through events:

* **Runs**: Workflow execution instances (materialized in storage)
* **Steps**: Individual atomic operations within a workflow (materialized in storage)
* **Hooks**: Suspension points that can receive external data (materialized in storage)
* **Waits**: Sleep or delay operations (materialized in storage)

## Entity Lifecycles

Each entity type follows a specific lifecycle defined by the events that can affect it. Events transition entities between states, and certain states are terminal—once reached, no further transitions are possible.

<Callout type="info">
  In the diagrams below, <span style={{color: '#8b5cf6', fontWeight: 'bold'}}>purple nodes</span> indicate terminal states that cannot be transitioned out of.
</Callout>

### Run Lifecycle

A run represents a single execution of a workflow function. Runs begin in `pending` state when created, transition to `running` when execution starts, and end in one of three terminal states.

<Mermaid
  chart="flowchart TD
    A[&#x22;(start)&#x22;] -->|&#x22;run_created&#x22;| B[&#x22;pending&#x22;]
    B -->|&#x22;run_started&#x22;| C[&#x22;running&#x22;]
    C -->|&#x22;run_completed&#x22;| D[&#x22;completed&#x22;]
    C -->|&#x22;run_failed&#x22;| E[&#x22;failed&#x22;]
    C -->|&#x22;run_cancelled&#x22;| F[&#x22;cancelled&#x22;]
    B -->|&#x22;run_cancelled&#x22;| F

    style D fill:#a78bfa,stroke:#8b5cf6,color:#000
    style E fill:#a78bfa,stroke:#8b5cf6,color:#000
    style F fill:#a78bfa,stroke:#8b5cf6,color:#000"
/>

**Run states:**

* `pending`: Created but not yet executing
* `running`: Actively executing workflow code
* `completed`: Finished successfully with an output value
* `failed`: Terminated due to an unrecoverable error
* `cancelled`: Explicitly cancelled by the user or system

### Step Lifecycle

A step represents a single invocation of a step function. Steps can retry on failure, either transitioning back to `pending` via `step_retrying` or being re-executed directly with another `step_started` event.

<Mermaid
  chart="flowchart TD
    A[&#x22;(start)&#x22;] -->|&#x22;step_created&#x22;| B[&#x22;pending&#x22;]
    B -->|&#x22;step_started&#x22;| C[&#x22;running&#x22;]
    C -->|&#x22;step_completed&#x22;| D[&#x22;completed&#x22;]
    C -->|&#x22;step_failed&#x22;| E[&#x22;failed&#x22;]
    C -.->|&#x22;step_retrying&#x22;| B

    style D fill:#a78bfa,stroke:#8b5cf6,color:#000
    style E fill:#a78bfa,stroke:#8b5cf6,color:#000"
/>

**Step states:**

* `pending`: Created but not yet executing, or waiting to retry
* `running`: Actively executing step code
* `completed`: Finished successfully with a result value
* `failed`: Terminated after exhausting all retry attempts
* `cancelled`: Reserved for future use (not currently emitted)

<Callout type="info">
  The `step_retrying` event is optional. Steps can retry without it - the retry mechanism works regardless of whether this event is emitted. You may see back-to-back `step_started` events in logs when a step retries after a timeout or when the error is not explicitly captured. See [Errors and Retries](/docs/foundations/errors-and-retries) for more on how retries work.
</Callout>

When present, the `step_retrying` event moves a step back to `pending` state and records the error that caused the retry. This provides two benefits:

* **Cleaner observability**: The event log explicitly shows retry transitions rather than consecutive `step_started` events
* **Error history**: The error that triggered the retry is preserved for debugging

### Hook Lifecycle

A hook represents a suspension point that can receive external data, created by [`createHook()`](/docs/api-reference/workflow/create-hook). Hooks enable workflows to pause and wait for external events, user interactions, or HTTP requests. Webhooks (created with [`createWebhook()`](/docs/api-reference/workflow/create-webhook)) are a higher-level abstraction built on hooks that adds automatic HTTP request/response handling.

Hooks can receive multiple payloads while active and are disposed when no longer needed.

<Mermaid
  chart="flowchart TD
    A[&#x22;(start)&#x22;] -->|&#x22;hook_created&#x22;| B[&#x22;active&#x22;]
    A -->|&#x22;hook_conflict&#x22;| D[&#x22;conflicted&#x22;]
    B -->|&#x22;hook_received&#x22;| B
    B -->|&#x22;hook_disposed&#x22;| C[&#x22;disposed&#x22;]

    style C fill:#a78bfa,stroke:#8b5cf6,color:#000
    style D fill:#a78bfa,stroke:#8b5cf6,color:#000"
/>

**Hook states:**

* `active`: Ready to receive payloads (hook exists in storage)
* `disposed`: No longer accepting payloads (hook is deleted from storage)
* `conflicted`: Hook creation failed because the token is already in use by another workflow

Unlike other entities, hooks don't have a `status` field—the states above are conceptual. An "active" hook is one that exists in storage, while "disposed" means the hook has been deleted. When a `hook_disposed` event is created, the hook record is removed rather than updated.

While a hook is active, its token is reserved and cannot be used by other workflows. If a workflow attempts to create a hook with a token that is already in use by another active hook, a `hook_conflict` event is recorded instead of `hook_created`. Current worlds include the token and the run ID that currently owns it, though older persisted events or world implementations may only include the token. This causes `hook.getConflict()` to resolve with the conflicting run and the hook's payload promise to reject with a `HookConflictError`, which you can detect with `HookConflictError.is(error)`. See the [hook-conflict error](/docs/errors/hook-conflict) documentation for more details.

When a hook is disposed (either explicitly or when its workflow completes), the token is released and can be claimed by future workflows. Hooks are automatically disposed when a workflow reaches a terminal state (`completed`, `failed`, or `cancelled`). The `hook_disposed` event is only needed for explicit disposal before workflow completion.

See [Hooks & Webhooks](/docs/foundations/hooks) for more on how hooks and webhooks work.

### Wait Lifecycle

A wait represents a sleep operation created by [`sleep()`](/docs/api-reference/workflow/sleep). Waits track when a delay period has elapsed.

<Mermaid
  chart="flowchart TD
    A[&#x22;(start)&#x22;] -->|&#x22;wait_created&#x22;| B[&#x22;waiting&#x22;]
    B -->|&#x22;wait_completed&#x22;| C[&#x22;completed&#x22;]

    style C fill:#a78bfa,stroke:#8b5cf6,color:#000"
/>

**Wait states:**

* `waiting`: Delay period has not yet elapsed
* `completed`: Delay period has elapsed, workflow can resume

<Callout type="info">
  Like Runs, Steps, and Hooks, waits are materialized as entities in storage. When a `wait_created` event is processed, a wait entity is created with status `waiting`. When a `wait_completed` event is processed, the wait entity is atomically transitioned to `completed` — this guarantees that a wait can only be completed exactly once, even if multiple concurrent invocations attempt to complete it simultaneously.
</Callout>

## Event Types Reference

Events are categorized by the entity type they affect. Each event contains metadata including a timestamp and a `correlationId` that links the event to a specific entity:

* Step events use the `stepId` as the correlation ID
* Hook events use the `hookId` as the correlation ID
* Wait events use the `waitId` as the correlation ID
* Run events do not require a correlation ID since the `runId` itself identifies the entity

### Run Events

| Event           | Description                                                                                                                                |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `run_created`   | Creates a new workflow run in `pending` state. Contains the deployment ID, workflow name, input arguments, and optional execution context. |
| `run_started`   | Transitions the run to `running` state when execution begins.                                                                              |
| `run_completed` | Transitions the run to `completed` state with the workflow's return value.                                                                 |
| `run_failed`    | Transitions the run to `failed` state with error details and optional error code.                                                          |
| `run_cancelled` | Transitions the run to `cancelled` state. Can be triggered from `pending` or `running` states.                                             |

### Step Events

| Event            | Description                                                                                                                                                                                                                    |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `step_created`   | Creates a new step in `pending` state. Contains the step name and serialized input arguments.                                                                                                                                  |
| `step_started`   | Transitions the step to `running` state. Includes the current attempt number for retries.                                                                                                                                      |
| `step_completed` | Transitions the step to `completed` state with the step's return value.                                                                                                                                                        |
| `step_failed`    | Transitions the step to `failed` state with error details. The step will not be retried.                                                                                                                                       |
| `step_retrying`  | (Optional) Transitions the step back to `pending` state for retry. Contains the error that caused the retry and optional delay before the next attempt. When not emitted, retries appear as consecutive `step_started` events. |

### Hook Events

| Event           | Description                                                                                                                                                                                                                                                                                                                  |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `hook_created`  | Creates a new hook in `active` state. Contains the hook token and optional metadata.                                                                                                                                                                                                                                         |
| `hook_conflict` | Records that hook creation failed because the token is already in use by another active hook. Contains the token and, for current worlds, the active hook owner's run ID. The hook is not created: `hook.getConflict()` resolves with the conflicting run, and awaiting the hook payload rejects with a `HookConflictError`. |
| `hook_received` | Records that a payload was delivered to the hook. The hook remains `active` and can receive more payloads.                                                                                                                                                                                                                   |
| `hook_disposed` | Deletes the hook from storage (conceptually transitioning to `disposed` state). The token is released for reuse by future workflows.                                                                                                                                                                                         |

### Wait Events

| Event            | Description                                                                                  |
| ---------------- | -------------------------------------------------------------------------------------------- |
| `wait_created`   | Creates a new wait in `waiting` state. Contains the timestamp when the wait should complete. |
| `wait_completed` | Transitions the wait to `completed` state when the delay period has elapsed.                 |

## Terminal States

Terminal states represent the end of an entity's lifecycle. Once an entity reaches a terminal state, no further events can transition it to another state.

**Run terminal states:**

* `completed`: Workflow finished successfully
* `failed`: Workflow encountered an unrecoverable error
* `cancelled`: Workflow was explicitly cancelled

**Step terminal states:**

* `completed`: Step finished successfully
* `failed`: Step failed after all retry attempts

**Hook terminal states:**

* `disposed`: Hook has been deleted from storage and is no longer active
* `conflicted`: Hook creation failed due to token conflict (hook was never created)

**Wait terminal states:**

* `completed`: Delay period has elapsed

Attempting to create an event that would transition an entity out of a terminal state will result in an error. This prevents inconsistent state and ensures the integrity of the event log.

## Event Correlation

Events use a `correlationId` to link related events together. For step, hook, and wait events, the correlation ID identifies the specific entity instance:

* Step events share the same `correlationId` (the step ID) across all events for that step execution
* Hook events share the same `correlationId` (the hook ID) across all events for that hook
* Wait events share the same `correlationId` (the wait ID) across creation and completion

Run events do not require a correlation ID since the `runId` itself provides the correlation.

This correlation enables:

* Querying all events for a specific step, hook, or wait
* Building timelines of entity lifecycle transitions
* Debugging by tracing the complete history of any entity

### Request ID Correlation

Some `World` implementations also attach a `requestId` to events for platform-log correlation. This is different from `correlationId`:

* `correlationId` links together events for the same entity lifecycle
* `requestId` tells you which inbound platform request created or updated the event

On Vercel, `requestId` is the platform request ID when available. Other worlds are not expected to provide a `requestId`.

```json
{
  "eventType": "step_started",
  "correlationId": "step_01JQEXAMPLE1234567890",
  "requestId": "iad1::abc123-1712345678901-xyz987"
}
```

## Entity IDs

All entities in the Workflow SDK use a consistent ID format: a 4-character prefix followed by an underscore and a [ULID](https://github.com/ulid/spec) (Universally Unique Lexicographically Sortable Identifier).

| Entity | Prefix  | Example                         |
| ------ | ------- | ------------------------------- |
| Run    | `wrun_` | `wrun_01HXYZ123ABC456DEF789GHJ` |
| Step   | `step_` | `step_01HXYZ123ABC456DEF789GHJ` |
| Hook   | `hook_` | `hook_01HXYZ123ABC456DEF789GHJ` |
| Wait   | `wait_` | `wait_01HXYZ123ABC456DEF789GHJ` |
| Event  | `evnt_` | `evnt_01HXYZ123ABC456DEF789GHJ` |
| Stream | `strm_` | `strm_01HXYZ123ABC456DEF789GHJ` |

**Why this format?**

* **Prefixes enable introspection**: Given any ID, you can immediately identify what type of entity it refers to. This makes debugging, logging, and cross-referencing entities across the system straightforward.

* **ULIDs enable chronological ordering**: Unlike UUIDs, ULIDs encode a timestamp in their first 48 bits, making them lexicographically sortable by creation time. This property is essential for the event log—events are always stored and retrieved in the correct chronological order simply by sorting their IDs.


---
title: Framework Integrations
description: Guide for framework authors to integrate Workflow SDK with custom frameworks or runtimes.
type: guide
summary: Build a custom framework integration using the Workflow SDK compiler and runtime.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/deploying/building-a-world
---

# Framework Integrations



<Callout>
  **For users:** If you just want to use Workflow SDK with an existing framework, check out the [Getting Started](/docs/getting-started) guide instead. This page is for framework authors who want to integrate Workflow SDK with their framework or runtime.
</Callout>

This guide walks you through building a framework integration for Workflow SDK using Bun as a concrete example. The same principles apply to any JavaScript runtime (Node.js, Deno, Cloudflare Workers, etc.).

<Callout type="info">
  **Prerequisites:** Before building a framework integration, we recommend reading [How the Directives Work](/docs/how-it-works/code-transform) to understand the transformation system that powers Workflow SDK.
</Callout>

## What You'll Build

A framework integration has two main components:

1. **Build-time**: Generate workflow handler files (`flow.js`, `step.js`, `webhook.js`)
2. **Runtime**: Expose these handlers as HTTP endpoints in your application server

<Mermaid
  chart="flowchart TD
    A[&#x22;Source Code<br/>'use workflow'&#x22;] --> B[&#x22;Workflow Builder&#x22;]
    B --> C[&#x22;SWC Transform&#x22;]
    C --> D[&#x22;Step Mode&#x22;]
    C --> E[&#x22;Workflow Mode&#x22;]
    C --> F[&#x22;Client Mode&#x22;]
    D --> G[&#x22;Generated Handlers<br/>step.js&#x22;]
    E --> H[&#x22;Generated Handlers<br/>flow.js&#x22;]
    B --> L[&#x22;Generated Handlers<br/>webhook.js&#x22;]
    F --> I[&#x22;Used by framework loader&#x22;]
    G --> J[&#x22;HTTP Server<br/>(Your Runtime)&#x22;]
    H --> J
    L --> J

    style B fill:#a78bfa,stroke:#8b5cf6,color:#000
    style I fill:#a78bfa,stroke:#8b5cf6,color:#000
    style J fill:#a78bfa,stroke:#8b5cf6,color:#000"
/>

The purple boxes are what you implement—everything else is provided by Workflow SDK.

## Example: Bun Integration

Let's build a complete integration for Bun. Bun is unique because it serves as both a runtime (needs code transformations) and a framework (provides `Bun.serve()` for HTTP routing).

<Callout type="info">
  A working example can be [found here](https://github.com/vercel/workflow-examples/tree/main/custom-adapter). For a production-ready reference, see the [Next.js integration](https://github.com/vercel/workflow/tree/main/packages/next).
</Callout>

### Step 1: Generate Handler Files

Use the `workflow` CLI to generate the handler bundles. The CLI scans your `workflows/` directory and creates `flow.js`, `step.js`, and `webhook.js`.

```json title="package.json"
{
  "scripts": {
    "dev": "bun x workflow build && PORT=3152 bun run server.ts"
  }
}
```

<Callout>
  **For production integrations:** Instead of using the CLI, extend the `BaseBuilder` class directly in your framework plugin. This gives you control over file watching, custom output paths, and framework-specific hooks. See the [Next.js plugin](https://github.com/vercel/workflow/tree/main/packages/next) for an example.
</Callout>

**What gets generated:**

* `/.well-known/workflow/v1/flow.js` - Handles workflow execution (workflow mode transform)
* `/.well-known/workflow/v1/step.js` - Handles step execution (step mode transform)
* `/.well-known/workflow/v1/webhook.js` - Handles webhook delivery

Each file exports a `POST` function that accepts Web standard `Request` objects.

### Step 2: Add Client Mode Transform (Optional)

Client mode transforms your application code to provide better DX. Add a Bun plugin to apply this transformation at runtime:

{/* @skip-typecheck: incomplete code sample */}

```typescript title="workflow-plugin.ts" lineNumbers
import { plugin } from "bun";
import { transform } from "@swc/core";

plugin({
  name: "workflow-transform",
  setup(build) {
    build.onLoad({ filter: /\.(ts|tsx|js|jsx)$/ }, async (args) => {
      const source = await Bun.file(args.path).text();

      // Optimization: Skip files that do not have any directives
      if (!source.match(/(use step|use workflow)/)) {
        return { contents: source };
      }

      const result = await transform(source, {
        filename: args.path,
        jsc: {
          experimental: {
            plugins: [
              [require.resolve("@workflow/swc-plugin"), { mode: "client" }], // [!code highlight]
            ],
          },
        },
      });

      return { contents: result.code, loader: "ts" };
    });
  },
});
```

Activate the plugin in `bunfig.toml`:

```toml title="bunfig.toml"
preload = ["./workflow-plugin.ts"]
```

**What this does:**

* Attaches workflow IDs to functions for use with `start()`
* Provides TypeScript type safety
* Prevents accidental direct execution of workflows

**Why optional?** Without client mode, you can still use workflows by manually constructing IDs or referencing the build manifest.

### Step 3: Expose HTTP Endpoints

Wire up the generated handlers to HTTP endpoints using `Bun.serve()`:

{/* @skip-typecheck: incomplete code sample */}

```typescript title="server.ts" lineNumbers
import flow from "./.well-known/workflow/v1/flow.js";
import step from "./.well-known/workflow/v1/step.js";
import * as webhook from "./.well-known/workflow/v1/webhook.js";

import { start } from "workflow/api";
import { handleUserSignup } from "./workflows/user-signup.js";

const server = Bun.serve({
  port: process.env.PORT,
  routes: {
    "/.well-known/workflow/v1/flow": {
      POST: (req) => flow.POST(req),
    },
    "/.well-known/workflow/v1/step": {
      POST: (req) => step.POST(req),
    },
    // webhook exports handlers for GET, POST, DELETE, etc.
    "/.well-known/workflow/v1/webhook/:token": webhook,

    // Example: Start a workflow
    "/": {
      GET: async (req) => {
        const email = `test-${crypto.randomUUID()}@test.com`;
        const run = await start(handleUserSignup, [email]);
        return Response.json({
          message: "User signup workflow started",
          runId: run.runId,
        });
      },
    },
  },
});

console.log(`Server listening on http://localhost:${server.port}`);
```

**That's it!** Your Bun integration is complete.

## Understanding the Endpoints

Your integration must expose three HTTP endpoints. The generated handlers manage all protocol details—you just route requests.

### Workflow Endpoint

**Route:** `POST /.well-known/workflow/v1/flow`

Executes workflow orchestration logic. The workflow function is "rendered" multiple times during execution—each time it progresses until it encounters the next step.

**Called when:**

* Starting a new workflow
* Resuming after a step completes
* Resuming after a webhook or hook triggers
* Recovering from failures

### Step Endpoint

**Route:** `POST /.well-known/workflow/v1/step`

Executes individual atomic operations within workflows. Each step runs exactly once per execution (unless retried due to failure). Steps have full runtime access (Node.js APIs, file system, databases, etc.).

### Webhook Endpoint

**Route:** `POST /.well-known/workflow/v1/webhook/:token`

Delivers webhook data to running workflows via [`createWebhook()`](/docs/api-reference/workflow/create-webhook). The `:token` parameter identifies which workflow run should receive the data.

<Callout type="info">
  The webhook file structure varies by framework. Next.js generates `webhook/[token]/route.js` to leverage App Router's dynamic routing, while other frameworks generate a single `webhook.js` handler.
</Callout>

## Adapting to Other Frameworks

The Bun example demonstrates the core pattern. To adapt for your framework:

### Build-Time

**Option 1: Use the CLI** (simplest)

```bash
workflow build
```

This will default to scanning the `./workflows` top-level directory for workflow files, and will output bundled files directly into your working directory.

**Option 2: Extend `BaseBuilder`** (recommended)

{/* @skip-typecheck: @workflow/cli internal module */}

```typescript lineNumbers
import { BaseBuilder } from "@workflow/cli/dist/lib/builders/base-builder";

class MyFrameworkBuilder extends BaseBuilder {
  constructor(options) {
    super({
      dirs: ["workflows"],
      workingDir: options.rootDir,
      watch: options.dev,
    });
  }

  override async build(): Promise<void> {
    const inputFiles = await this.getInputFiles();

    await this.createWorkflowsBundle({
      outfile: "/path/to/.well-known/workflow/v1/flow.js",
      format: "esm",
      inputFiles,
    });

    await this.createStepsBundle({
      outfile: "/path/to/.well-known/workflow/v1/step.js",
      format: "esm",
      inputFiles,
    });

    await this.createWebhookBundle({
      outfile: "/path/to/.well-known/workflow/v1/webhook.js",
    });
  }
}
```

If your framework supports virtual server routes and dev mode watching, make sure to adapt accordingly. Please open a PR to the Workflow SDK if the base builder class is missing necessary functionality.

### Monorepos and Workspace Imports

If your framework integration lives in a subdirectory and your workflows import code from sibling workspace packages, pass `projectRoot` to `BaseBuilder`. Use the smallest directory that contains every workspace package imported by your workflows.

{/* @skip-typecheck: @workflow/cli internal module */}

```typescript title="my-framework-builder.ts" lineNumbers
import { BaseBuilder } from "@workflow/cli/dist/lib/builders/base-builder";

class MyFrameworkBuilder extends BaseBuilder {
  constructor(options: {
    rootDir: string;
    workspaceRoot?: string;
    dev: boolean;
  }) {
    super({
      dirs: ["workflows"],
      workingDir: options.rootDir,
      projectRoot: options.workspaceRoot ?? options.rootDir, // [!code highlight]
      watch: options.dev,
    });
  }

  override async build(): Promise<void> {
    const inputFiles = await this.getInputFiles();
    // ...
  }
}
```

Hook into your framework's build:

{/* @skip-typecheck: incomplete code sample */}

```typescript title="pseudocode.ts" lineNumbers
framework.hooks.hook("build:before", async () => {
  await new MyFrameworkBuilder(framework).build();
});
```

### Runtime (Client Mode)

Add a loader/plugin for your bundler:

**Rollup/Vite:**

```typescript lineNumbers
export function workflowPlugin() {
  return {
    name: "workflow-client-transform",
    async transform(code, id) {
      if (!code.match(/(use step|use workflow)/)) return null;

      const result = await transform(code, {
        filename: id,
        jsc: {
          experimental: {
            plugins: [[require.resolve("@workflow/swc-plugin"), { mode: "client" }]], // [!code highlight]
          },
        },
      });

      return { code: result.code, map: result.map };
    },
  };
}
```

**Webpack:**

```javascript lineNumbers
module.exports = {
  module: {
    rules: [
      {
        test: /\.(ts|tsx|js|jsx)$/,
        use: "workflow-client-loader", // Similar implementation
      },
    ],
  },
};
```

### HTTP Server

Route the three endpoints to the generated handlers. The exact implementation depends on your framework's routing API.

In the bun example above, we left routing to the user. Essentially, the user has to serve routes like this:

{/* @skip-typecheck: incomplete code sample */}

```typescript title="server.ts" lineNumbers
import flow from "./.well-known/workflow/v1/flow.js";
import step from "./.well-known/workflow/v1/step.js";
import * as webhook from "./.well-known/workflow/v1/webhook.js";

// Expose the 3 generated routes
const server = Bun.serve({
  routes: {
    "/.well-known/workflow/v1/flow": {
      POST: (req) => flow.POST(req),
    },
    "/.well-known/workflow/v1/step": {
      POST: (req) => step.POST(req),
    },
    // webhook exports handlers for GET, POST, DELETE, etc.
    "/.well-known/workflow/v1/webhook/:token": webhook,
  },
});
```

Production framework integrations should handle this routing in the plugin instead of leaving it to the user, and this depends on each framework's unique implementaiton.
Check the Workflow SDK source code for examples of production framework implementations.
In the future, the Workflow SDK will emit more routes under the `.well-known/workflow` namespace.

## Security

The workflow and step handler endpoints are invoked by the world's queuing infrastructure, not by end users. How they're secured depends on which world you're deploying to.

### Vercel (`@workflow/world-vercel`)

On Vercel, workflow handler functions are not accessible through public endpoints. Handlers use the same [consumer function security](https://vercel.com/docs/queues/concepts#consumer-function-security) mechanism that secures [Vercel Queues](https://vercel.com/docs/queues) consumers.

During the build step, the Workflow SDK automatically configures each handler as a queue consumer by writing `experimentalTriggers` to the function's `.vc-config.json`:

```json title=".vc-config.json (generated by Workflow SDK)"
{
  "experimentalTriggers": [
    {
      "type": "queue/v2beta",
      "topic": "__wkf_step_*",
      "consumer": "default",
      "retryAfterSeconds": 5,
      "initialDelaySeconds": 0
    }
  ]
}
```

Two queue topics are created per deployment:

| Handler     | Topic              | Description                                       |
| ----------- | ------------------ | ------------------------------------------------- |
| `step.func` | `__wkf_step_*`     | Step execution (long-running, `maxDuration: max`) |
| `flow.func` | `__wkf_workflow_*` | Workflow orchestration (`maxDuration: 60`)        |

If you're building a framework integration that targets Vercel, you should write these triggers into the `.vc-config.json` for each generated function. The `STEP_QUEUE_TRIGGER` and `WORKFLOW_QUEUE_TRIGGER` constants are exported from `@workflow/builders` for this purpose:

```typescript
import { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from "@workflow/builders";
```

### Custom implementations

For self-hosted or non-Vercel deployments, you are responsible for securing the handler endpoints:

* **Framework middleware** — Add authentication (API keys, JWT, OIDC) in front of the `/.well-known/workflow/v1/*` routes
* **Network-level security** — Deploy handlers behind a VPC, private network, or firewall rules so only your queue infrastructure can reach them
* **Rate limiting** — Add request validation and rate limiting to prevent abuse

Learn more about [building custom Worlds](/docs/deploying/building-a-world).

## Testing Your Integration

### 1. Test Build Output

Create a test workflow:

```typescript title="workflows/test.ts" lineNumbers
import { sleep, createWebhook } from "workflow";

export async function handleUserSignup(email: string) {
  "use workflow";

  const user = await createUser(email);
  await sendWelcomeEmail(user);

  await sleep("5s");

  const webhook = createWebhook();
  await sendOnboardingEmail(user, webhook.url);

  await webhook;
  console.log("Webhook Resolved");

  return { userId: user.id, status: "onboarded" };
}

async function createUser(email: string) {
  "use step";

  console.log(`Creating a new user with email: ${email}`);

  return { id: crypto.randomUUID(), email };
}

async function sendWelcomeEmail(user: { id: string; email: string }) {
  "use step";

  console.log(`Sending welcome email to user: ${user.id}`);
}

async function sendOnboardingEmail(user: { id: string; email: string }, callback: string) {
  "use step";

  console.log(`Sending onboarding email to user: ${user.id}`);

  console.log(`Click this link to resolve the webhook: ${callback}`);
}

```

Run your build and verify:

* `.well-known/workflow/v1/flow.js` exists
* `.well-known/workflow/v1/step.js` exists
* `.well-known/workflow/v1/webhook.js` exists

### 2. Test HTTP Endpoints

Start your server and verify routes respond:

```bash
curl -X POST http://localhost:3000/.well-known/workflow/v1/flow
curl -X POST http://localhost:3000/.well-known/workflow/v1/step
curl -X POST http://localhost:3000/.well-known/workflow/v1/webhook/test
```

(Should respond but not trigger meaningful code without authentication/proper workflow run)

### 3. Run a Workflow End-to-End

```typescript
import { start } from "workflow/api";
import { handleUserSignup } from "./workflows/test";

const run = await start(handleUserSignup, ["test@example.com"]);
console.log("Workflow started:", run.runId);
```


---
title: Understanding Directives
description: Explore how JavaScript directives enable the Workflow SDK's durable execution model.
type: conceptual
summary: Explore the design decisions behind "use workflow" and "use step" directives.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/how-it-works/code-transform
---

# Understanding Directives



import { File, Folder, Files } from "fumadocs-ui/components/files";

<Callout>
  This guide explores how JavaScript directives enable the Workflow SDK's execution model. For getting started with workflows, see the [getting started](/docs/getting-started) guides for your framework.
</Callout>

The Workflow SDK uses JavaScript directives (`"use workflow"` and `"use step"`) as the foundation for its durable execution model. Directives provide the compile-time semantic boundary necessary for workflows to suspend, resume, and maintain deterministic behavior across replays.

This page explores how directives enable this execution model and the design principles that led us here.

To understand how directives work, let's first understand what workflows and steps are in the Workflow SDK.

## Workflows and Steps Primer

The Workflow SDK has two types of functions:

**Step functions** are side-effecting operations with full Node.js runtime access. Think of them like named RPC calls - they run once, their result is persisted, and they can be [retried on failure](/docs/foundations/errors-and-retries):

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
async function fetchUserData(userId: string) {
  "use step";

  // Full Node.js access: database calls, API requests, file I/O
  const user = await db.query("SELECT * FROM users WHERE id = ?", [userId]);
  return user;
}
```

**Workflow functions** are deterministic orchestrators that coordinate steps. They must be pure functions - during replay, the same step results always produce the same output. This is necessary because workflows resume by replaying their code from the beginning using cached step results; non-deterministic logic would break resumption. They run in a sandboxed environment without direct Node.js access:

```typescript lineNumbers
export async function onboardUser(userId: string) {
  "use workflow";

  const user = await fetchUserData(userId); // Calls step

  // Non-deterministic code would break replay behavior // [!code highlight]
  if (Math.random() > 0.5) { // [!code highlight]
    await sendWelcomeEmail(user); // [!code highlight]
  } // [!code highlight]

  return `Onboarded ${user.name}!`;
}
```

**The key insight:** Workflows resume from suspension by replaying their code using cached step results from the [event log](/docs/how-it-works/event-sourcing). When a step like `await fetchUserData(userId)` is called:

* **If already executed:** Returns the cached result immediately from the event log
* **If not yet executed:** Suspends the workflow, enqueues the step for background execution, and resumes later with the result

This replay mechanism requires deterministic code. If `Math.random()` weren't seeded, the first execution might return `0.7` (sending the email) but replay might return `0.3` (skipping it), thus breaking resumption. The Workflow SDK sandbox provides seeded `Math.random()` and `Date` to ensure consistent behavior across replays.

<Callout>
  For a deeper dive into workflows and steps, see [Workflows and Steps](/docs/foundations/workflows-and-steps).
</Callout>

## The Core Challenge

This execution model enables powerful durability features - workflows can suspend for days, survive restarts, and resume from any point. However, it also requires a semantic boundary in the code that tells **the compiler, runtime, and developer** that execution semantics have changed.

The challenge: how do we mark this boundary in a way that:

1. Enables compile-time transformations and validation
2. Prevents accidental use of non-deterministic APIs
3. Allows static analysis of workflow structure
4. Feels natural to JavaScript developers

Let's look at where directives have been used before, and the alternatives we considered:

## Prior art on directives

JavaScript directives have precedent for changing execution semantics within a defined scope:

* `"use strict"` (introduced in ECMAScript 5 in 2009, TC39-standardized) changes language rules to make the runtime faster, safer, and more predictable.
* `"use client"` and `"use server"` (introduced by [React Server Components](https://react.dev/reference/rsc/server-components)) define an explicit boundary of "where" code gets executed - client-side browser JavaScript vs server-side Node.js.
* `"use workflow"` (introduced by the Workflow SDK) defines both "where" code runs (in a deterministic sandbox environment) and "how" it runs (deterministic, resumable, sandboxed execution semantics).

Directives provide a build-time contract.

When the Workflow SDK sees `"use workflow"`, it:

* Bundles the workflow and its dependencies into code that can be run in a sandbox
* Restricts access to Node.js APIs in that sandbox
* Enables future functionality and optimizations only possible with a build tool
  * For instance, the bundled workflow code can be statically analyzed to generate UML diagrams/visualizations of the workflow

In addition to being important to the compiler, `"use workflow"` explicitly signals to the developer that you are entering a different execution mode.

<Callout type="info">
  The `"use workflow"` directive is also used by the Language Server Plugin shipped with Workflow SDK to provide IntelliSense to your IDE. Check the [getting started instructions](/docs/getting-started) for your framework for details on setting up the Language Server Plugin.
</Callout>

But we didn't get here immediately. This took some discovery to arrive at:

## Alternatives We Explored

Before settling on directives, we prototyped several other approaches. Each had significant limitations that made them unsuitable for production use.

### Runtime-Only "Suspense" API

Our first proof of concept used a wrapper-based API without a build step:

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
export const myWorkflow = workflow(() => {
  const message = run(async () => step());
  return `${message}!`;
});
```

This implementation used "throwing promises" (similar to early React Suspense) to suspend execution. When a step needed to run, we'd throw a promise, catch it at the workflow boundary, execute the step, and replay the workflow with the result.

**The problems:**

**1. Every side effect needed wrapping**

Any operation that could produce non-deterministic results had to be wrapped in `run()`:

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
export const myWorkflow = workflow(async () => {
  // These would be non-deterministic without wrapping
  const now = await run(() => Date.now()); // [!code highlight]
  const random = await run(() => Math.random()); // [!code highlight]
  const user = await run(() => fetchUser()); // [!code highlight]

  return { now, random, user };
});
```

This was verbose and easy to forget. Moreover, if a developer forgot to wrap something innocent like using `Date.now()`, it led to unstable runtime behavior.

For example:

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
export const myWorkflow = workflow(async () => {
  // Nothing stops you from doing this:
  const now = Date.now(); // Non-deterministic, untracked! // [!code highlight]
  const user = await run(() => fetchUser());

  // This workflow would produce different results on replay // [!code highlight]
  return { now, user };
});
```

**2. Closures and mutation became unpredictable**

Variables captured in closures would behave unexpectedly when steps mutated them:

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
export const myWorkflow = workflow(async () => {
  let counter = 0;

  await run(() => {
    counter++; // This mutation happens during step execution // [!code highlight]
    return saveToDatabase(counter);
  });

  console.log(counter); // What is counter here? // [!code highlight]
  // During execution: 1 (mutation preserved) // [!code highlight]
  // During replay: 0 (mutation lost) // [!code highlight]
  // Inconsistent behavior! // [!code highlight]
});
```

The workflow function would replay multiple times, but mutations inside `run()` callbacks wouldn't persist across replays. This made reasoning about state nearly impossible.

**3. Error handling broke down**

Since we used thrown promises for control flow, `try/catch` blocks became unreliable:

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
export const myWorkflow = workflow(async () => {
  try {
    const result = await run(() => step());
    return result;
  } catch (error) { // [!code highlight]
    // This could catch: // [!code highlight]
    // 1. A real error from the step // [!code highlight]
    // 2. The thrown promise used for suspension // [!code highlight]
    // 3. An error during replay // [!code highlight]
    // Hard to distinguish without special handling // [!code highlight]
    console.error(error);
  }
});
```

### Generator-Based API

We explored using generators for explicit suspension points, inspired by libraries like Effect.ts:

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
export const myWorkflow = workflow(function*() {
  const message = yield* run(() => step());
  return `${message}!`;
});
```

<Callout type="info">
  We're big fans of [Effect.ts](https://effect.website/) and the power of generator-based APIs for effect management. However, for workflow orchestration specifically, we found the syntax too heavy for developers unfamiliar with generators.
</Callout>

**The problems:**

**1. Syntax felt more like a DSL than JavaScript**

Generators require a custom mental model that differs significantly from familiar async/await patterns. The `yield*` syntax and generator delegation were unfamiliar to many developers:

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
// Standard async/await (familiar)
const result = await fetchData();

// Generator-based (unfamiliar)
const result = yield* run(() => fetchData()); // [!code highlight]
```

Complex workflows became particularly verbose and difficult to read:

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
export const myWorkflow = workflow(function*() {
  const user = yield* run(() => fetchUser());

  // Can't use Promise.all directly - need sequential calls or custom helpers // [!code highlight]
  const orders = yield* run(() => fetchOrders(user.id)); // [!code highlight]
  const payments = yield* run(() => fetchPayments(user.id)); // [!code highlight]

  // Or create a custom generator-aware parallel helper: // [!code highlight]
  const [orders2, payments2] = yield* all([ // [!code highlight]
    run(() => fetchOrders(user.id)), // [!code highlight]
    run(() => fetchPayments(user.id)) // [!code highlight]
  ]); // [!code highlight]

  return { user, orders, payments };
});
```

**2. Still no compile-time sandboxing**

Like the runtime-only approach, generators couldn't prevent non-deterministic code:

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
export const myWorkflow = workflow(function*() {
  const now = Date.now(); // Still possible, still problematic // [!code highlight]
  const user = yield* run(() => fetchUser());
  return { now, user };
});
```

The generator syntax addressed suspension but didn't solve the fundamental sandboxing problem.

### File System-Based Conventions

We explored using file system conventions to identify workflows and steps, similar to how modern frameworks handle routing (Next.js, Hono, Nitro, SvelteKit):

<Files>
  <Folder name="workflows" defaultOpen>
    <File name="onboarding.ts" />

    <File name="checkout.ts" />
  </Folder>

  <Folder name="steps" defaultOpen>
    <File name="send-email.ts" />

    <File name="charge-payment.ts" />
  </Folder>
</Files>

With this approach, any function in the `workflows/` directory would be transformed as a workflow, and any function in `steps/` would be a step. No directives needed, just file locations.

**Why this could work:**

* Clear separation of concerns
* Enables compiler transformations based on file path
* Familiar pattern for developers used to file-based routing, for example Next.js

**Why we moved away:**

**1. Too opinionated for diverse ecosystems**

Different frameworks and developers have strong opinions about project structure. Forcing a specific directory layout often caused conflicts across various conventions, especially in existing codebases.

**2. No support for publishable, reusable functions**

We want developers to be able to publish libraries to npm that include step and workflow directives. Ideally, logic that is isomorphic so it could be used with and without Workflow SDK. File system conventions made this impossible.

**3. Migration and code reuse became difficult**

Migrating existing code required moving files and restructuring projects rather than adding a single line.

The directive approach solved all these issues: it works in any project structure, supports code reuse and migration, enables npm packages, and allows functions to adapt to their execution context.

### Decorators

We considered decorators, but they presented significant challenges both technical and ergonomic.

**Decorators are non-yet-standard and class-focused**

Decorators are not yet a standard syntax ([TC39 proposal](https://github.com/tc39/proposal-decorators)) and they currently only work with classes. A class decorator approach could look like this:

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
import {workflow, step} from "workflow";

class MyWorkflow {
  @workflow() // [!code highlight]
  static async processOrder(orderId: string) { // [!code highlight]
    const order = await this.fetchOrder(orderId);
    const payment = await this.processPayment(order);
    return { orderId, payment };
  }

  @step() // [!code highlight]
  static async fetchOrder(orderId: string) { // [!code highlight]
    // ...
  }
}
```

This approach requires:

* Writing class boilerplate with static methods
* Storing/mutating class properties was not obvious (similar closure/mutation issues as the runtime-only approach)
* Class-based syntax that doesn't feel "JavaScript native" to developers used to functional patterns

As the JavaScript ecosystem has moved toward function-forward programming (exemplified by React's shift from class components to functions and hooks), requiring developers to use classes felt like a step backward and also didn't match our own personal taste as authors of the SDK.

**The core problem: Presents workflows as regular runtime code**

While decorators can be handled at compile-time with build tool support, they present workflow functions as if they were regular, composable JavaScript code, when they're actually compile-time declarations that need special handling.

<Callout>
  See the [Macro Wrapper](#macro-wrapper-approach) section below for a deeper dive into why this approach breaks down with concrete examples.
</Callout>

### Macro Wrapper Approach

We also explored compile-time macro approaches - using a compiler to transform wrapper functions or decorators into directive-based code:

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
// Function wrapper approach
import { useWorkflow } from "workflow"

export const processOrder = useWorkflow(async (orderId: string) => { // [!code highlight]
  const order = await fetchOrder(orderId);
  return { orderId };
});

// Decorator approach (would work similarly)
class MyWorkflow {
  @workflow() // [!code highlight]
  static async processOrder(orderId: string) {
    const order = await fetchOrder(orderId);
    return { orderId };
  }

  // ...
}
```

The compiler could transform both to be equivalent to Workflow SDK's directive approach:

```typescript lineNumbers
export const processOrder = async (orderId: string) => {
  "use workflow"; // [!code highlight]
  const order = await fetchOrder(orderId);
  return { orderId };
};
```

The benefit is that macros could enforce types and provide "Go To Definition" or other LSP features out of the box.

However, **the core problem remains: Workflows aren't runtime values**

The fundamental issue is that both wrappers and decorators make workflows appear to be **first-class, runtime values** when they're actually **compile-time declarations**. This mismatch between syntax and semantics creates numerous failure modes.

**Concrete examples of how this breaks:**

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
// Someone writes a "helpful" utility
function withRetry(fn: Function) {
  return useWorkflow(async (...args) => { // Works with useWorkflow // [!code highlight]
    try {
      return await fn(...args);
    } catch (error) {
      return await fn(...args); // Retry once
    }
  });
}

// Note: the same utility would be written similarly for a decorator based syntax

// Usage looks innocent in both cases
export const processOrder = withRetry(async (orderId: string) => { // [!code highlight]
  // Is this deterministic? Can it call steps?
  // Nothing in this function indicates the developer is in the
  // deterministic sandboxed workflow
  // Also where is the retry happening? inside or outside the workflow?
  const order = await fetchOrder(orderId);
  return order;
});
```

The developer writing `processOrder` has no visible signal that they're in a deterministic, sandboxed environment. It's also ambiguous whether the retry logic executes inside the workflow or outside, and the actual behavior likely doesn't match developer intuition.

**Why the compiler can't catch this:**

To detect that `processOrder` is actually a workflow, the compiler would need whole-program analysis to track that:

1. `withRetry` returns the result of `useWorkflow`
2. Therefore `processOrder = withRetry(...)` is a workflow
3. The function passed to `withRetry` will execute in a sandboxed context

This level of cross-function analysis is impractical for build tools - it would require analyzing every function call chain in your entire codebase and all dependencies. The compiler can only reliably detect direct `useWorkflow` calls, not calls hidden behind abstractions.

## How Directives Solve These Problems

Directives address all the issues we encountered with previous approaches:

**1. Compile-time semantic boundary**

The `"use workflow"` directive tells the compiler to treat this code differently:

```typescript lineNumbers
export async function processOrder(orderId: string) {
  "use workflow"; // Compiler knows: transform this for sandbox execution // [!code highlight]

  const order = await fetchOrder(orderId); // Compiler knows: this is a step call // [!code highlight]
  return { orderId, order };
}
```

**2. Build-time validation**

The compiler can enforce restrictions before deployment:

```typescript lineNumbers
export async function badWorkflow() {
  "use workflow";

  const crypto = require("crypto"); // Build error: Node.js module in workflow // [!code highlight]
  return crypto.randomBytes(16);
}
```

In fact, Workflow SDK will throw an error that links to this error page: [Node.js module in workflow](/docs/errors/node-js-module-in-workflow)

**3. No closure ambiguity**

Steps are transformed into function calls that communicate with the runtime:

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
export async function processOrder(orderId: string) {
  "use workflow";

  let counter = 0;

  // This essentially becomes: await enqueueStep("updateCounter", [counter])
  // The step receives counter as a parameter, not a closure
  await updateCounter(counter); // [!code highlight]

  console.log(counter); // Always 0, consistently // [!code highlight]
}
```

Callbacks, however, run inside the workflow sandbox and work as expected:

```typescript lineNumbers
export async function processOrders(orderIds: string[]) {
  "use workflow";

  let successCount = 0;

  // Callbacks run in the workflow context, not skipped on replay
  await Promise.all(orderIds.map(async (orderId) => {
    const order = await fetchOrder(orderId); // Step call
    if (order.status === "completed") {
      successCount++; // Mutation works correctly // [!code highlight]
    }
  }));

  console.log(successCount); // Consistent across replays
  return { total: orderIds.length, successful: successCount };
}
```

The callback runs in the workflow sandbox, so closure reads and mutations behave consistently across replays.

**4. Natural syntax**

Looks and feels like regular JavaScript:

```typescript lineNumbers
export async function processOrder(orderId: string) {
  "use workflow";

  // Standard async/await patterns work naturally // [!code highlight]
  const [order, user] = await Promise.all([ // [!code highlight]
    fetchOrder(orderId), // [!code highlight]
    fetchUser(userId) // [!code highlight]
  ]); // [!code highlight]

  return { order, user };
}
```

**5. Consistent syntax for steps**

The `"use step"` directive maintains consistency. While steps run in the full Node.js runtime and *could* work without a directive, they need some way to signal to the workflow runtime that they're steps.

We could have used a function wrapper just for steps:

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
// Mixed approach (inconsistent)
export async function processOrder(orderId: string) {
  "use workflow"; // Directive for workflow // [!code highlight]

  const order = await step(async () => fetchOrder(orderId));
  return order;
}

const fetchOrder = useStep(() => { // Wrapper for step? // [!code highlight]
  // ...
})
```

Mixing syntaxes felt inconsistent.

An alternative approach we considered was to treat *all* async function calls as steps by default:

```typescript lineNumbers
export async function processOrder(orderId: string) {
  "use workflow";

  // Every async call becomes a step automatically?
  const [order, user] = await Promise.all([ // [!code highlight]
    fetchOrder(orderId), // Step
    fetchUser(userId)    // Step
  ]);

  return { order, user };
}
```

This breaks down because many valid async operations inside workflows aren't steps:

{/* @skip-typecheck: incomplete code sample */}

```typescript lineNumbers
export async function processOrder(orderId: string) {
  "use workflow";

  // These are valid async calls that SHOULD NOT be steps:
  const results = await Promise.all([...]); // Language primitive // [!code highlight]
  const winner = await Promise.race([...]); // Language primitive // [!code highlight]

  // Helper function that formats data
  const formatted = await formatOrderData(order); // Pure JavaScript helper // [!code highlight]
}
```

By requiring explicit `"use step"` directives, developers have fine-grained control over what becomes a durable, retryable step versus what runs inline in the workflow sandbox.

<Callout>
  To understand how directives are transformed at compile time, see [How the Code Transform Works](/docs/how-it-works/code-transform).
</Callout>

## What Directives Enable

Because `"use workflow"` defines a compile-time semantic boundary, we can provide:

<Cards>
  <Card title="Build-Time Validation">
    The compiler catches invalid patterns before deployment: detects disallowed imports, prevents direct side effects, and validates workflow structure.
  </Card>

  <Card title="Static Analysis">
    Analyze workflow code without executing it: generate UML or DAG diagrams automatically, provide observability and visualization, and optimize execution paths.
  </Card>

  <Card title="Durable Execution">
    Workflows can safely suspend and resume: persist execution state between steps, resume from checkpoints after failures or deploys, and scale to zero without losing progress.
  </Card>

  <Card title="Future Optimizations">
    The semantic boundary enables planned improvements: smaller serialized state for faster checkpoints, smarter scheduling based on workflow structure, and more efficient suspension and resumption.
  </Card>
</Cards>

## Directives as a JavaScript Pattern

Directives in JavaScript have always been contracts between the developer and the execution environment. `"use strict"` made this pattern familiar - it's a string literal that changes how code is interpreted.

While JavaScript doesn't yet have first-class support for custom directives (like Rust's `#[attribute]` or C++'s `#pragma`), string literal directives are the most pragmatic tool available today.

As TC39 members, we at Vercel are actively working with the standards body and broader ecosystem to explore formal specifications for pragma-like syntax or macro annotations that can express execution semantics.

## Closing Thoughts

Directives aren't about syntax preference, they're about expressing semantic boundaries. `"use workflow"` tells the compiler, developer, and runtime that this code is deterministic, resumable, and sandboxed.

This clarity enables the Workflow SDK to provide durable execution with familiar JavaScript patterns, while maintaining the compile-time guarantees necessary for reliable workflow orchestration.


---
title: Internal
description: Preview-only page for internal tools, draft changelogs, and testing utilities.
type: overview
---

# Internal



<Callout type="warn">
  This page is only visible on preview deployments and local development. It does not appear in production.
</Callout>

## Preview Package

<PreviewInstall />

## Draft Changelogs

Changelog entries staged here for review before publishing to the Vercel website. There are currently no drafts staged for v4.


---
title: Migration Guides
description: Move your existing durable workflow system to the Workflow SDK with side-by-side code comparisons and a realistic migration example.
type: overview
summary: Migrate from Temporal, Inngest, AWS Step Functions, or trigger.dev to the Workflow SDK.
related:
  - /docs/foundations/workflows-and-steps
  - /docs/getting-started
---

# Migration Guides



<Callout type="info">
  Install the Workflow SDK migration skill:

  ```bash
  npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk
  ```
</Callout>

Move an existing orchestration system to the Workflow SDK. Each guide pairs a concept-mapping table with side-by-side code, so you can translate one piece of your codebase at a time.

<Cards>
  <Card href="/docs/migration-guides/migrating-from-temporal" title="Migrating from Temporal">
    Map Activities, Workers, Signals, and Child Workflows onto workflows, steps, hooks, and `start()` / `getRun()`.
  </Card>

  <Card href="/docs/migration-guides/migrating-from-inngest" title="Migrating from Inngest">
    Map `createFunction`, `step.run`, `step.sleep`, `step.waitForEvent`, and `step.invoke` onto workflows, steps, and hooks.
  </Card>

  <Card href="/docs/migration-guides/migrating-from-aws-step-functions" title="Migrating from AWS Step Functions">
    Replace ASL JSON states, Task / Choice / Wait / Parallel states, and `.waitForTaskToken` callbacks with TypeScript.
  </Card>

  <Card href="/docs/migration-guides/migrating-from-trigger-dev" title="Migrating from trigger.dev">
    Map `task()`, `schemaTask()`, `wait.for` / `wait.forToken`, `triggerAndWait`, and `metadata.stream` onto workflows, steps, hooks, and `start()` / `getRun()`.
  </Card>
</Cards>


---
title: Migrating from AWS Step Functions
description: Move an AWS Step Functions state machine to the Workflow SDK by replacing JSON state definitions, Task states, Choice/Wait/Parallel states, Retry/Catch blocks, and .waitForTaskToken callbacks with Workflows, Steps, Hooks, and idiomatic TypeScript control flow.
type: guide
summary: Translate an AWS Step Functions state machine into the Workflow SDK with side-by-side code examples.
prerequisites:
  - /docs/getting-started/next
  - /docs/foundations/workflows-and-steps
related:
  - /docs/foundations/starting-workflows
  - /docs/foundations/errors-and-retries
  - /docs/foundations/hooks
  - /docs/foundations/streaming
  - /docs/deploying/world/vercel-world
---

# Migrating from AWS Step Functions



Move an AWS Step Functions state machine to the Workflow SDK by replacing JSON state definitions with TypeScript functions. This guide shows the direct mapping between ASL states and Workflow SDK primitives.

<Callout type="info">
  Install the Workflow SDK migration skill:

  ```bash
  npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk
  ```
</Callout>

## Why migrate to the Workflow SDK

* Orchestration code is TypeScript, not JSON ASL. Transitions are `await`, branches are `if`/`switch`, and parallelism is `Promise.all`.
* Streaming is built in. Write durable progress from steps with `getWritable()` and named streams. No DynamoDB or SNS glue to surface status to clients.
* Infrastructure lives in one deployment. No separate state machine, per-task Lambda, IAM role wiring, or callback SQS queues.
* Error handling is TypeScript-native: step-level retries, `RetryableError`, and `FatalError` replace per-state Retry/Catch blocks.
* The `npx workflow` CLI and `npx workflow web` observability UI ship out of the box.
* AI/agent helpers — `@workflow/ai` for AI-SDK integration and the Claude migration skill — are available as separate installs.

## Before you migrate

This guide assumes **Standard** workflows. Express workflows have different semantics (at-least-once, 5-minute max duration, no execution history) and may need a different target — consider keeping them on Step Functions, moving them to a queue consumer, or ensuring your steps are idempotent before replaying the pattern here.

## What changes when you leave Step Functions?

AWS Step Functions defines workflows as JSON state machines using Amazon States Language (ASL). Each state (Task, Choice, Wait, Parallel, Map) is a node in a declarative graph. Lambda functions handle tasks, Retry/Catch blocks configure per-state error handling, and `.waitForTaskToken` manages callbacks.

The Workflow SDK replaces that JSON DSL with TypeScript. `"use workflow"` functions orchestrate `"use step"` functions in the same file. Branching is `if`/`else`. Waiting is `sleep()`. Parallelism is `Promise.all()`. Retries move down to the step level.

The migration replaces declarative configuration with idiomatic TypeScript and collapses the orchestrator and compute split. Business logic stays the same.

## Concept mapping

| AWS Step Functions                             | Workflow SDK                                                                                                                                                                                                                                        | Migration note                                                                                           |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| State machine (ASL JSON)                       | `"use workflow"` function                                                                                                                                                                                                                           | The workflow function is the state machine.                                                              |
| Task state / Lambda                            | `"use step"` function                                                                                                                                                                                                                               | Side effects go in steps. No separate Lambda.                                                            |
| Choice state                                   | `if` / `else` / `switch`                                                                                                                                                                                                                            | Native TypeScript control flow.                                                                          |
| Wait state                                     | `sleep()`                                                                                                                                                                                                                                           | Import `sleep` from `workflow`.                                                                          |
| Parallel state                                 | `Promise.all()`                                                                                                                                                                                                                                     | Standard concurrency primitives.                                                                         |
| Map state                                      | Inline sequential → `for` loop; bounded parallel (`MaxConcurrency: N`) → batched `Promise.all` or a concurrency limiter like `p-limit`; Distributed Map / large fan-out → step-wrapped `start()` per item, then step-wrapped `getRun()` to collect. | Match the concurrency mode of the original Map.                                                          |
| Retry / Catch                                  | Step retries, `RetryableError`, `FatalError`                                                                                                                                                                                                        | Retry logic moves to step boundaries.                                                                    |
| `Catch` to a compensation state                | `try`/`catch` in the workflow function, calling compensation steps in reverse order (push/pop a rollback stack)                                                                                                                                     | See [`/docs/foundations/errors-and-retries`](/docs/foundations/errors-and-retries) for the SAGA pattern. |
| `.waitForTaskToken`                            | `createHook()` or `createWebhook()`                                                                                                                                                                                                                 | Hooks for typed signals; webhooks for HTTP.                                                              |
| Child state machine (`StartExecution`)         | `"use step"` around `start()` / `getRun()`                                                                                                                                                                                                          | Return the `Run` object, await its result from another step.                                             |
| Execution event history                        | Workflow event log                                                                                                                                                                                                                                  | Same durable replay model.                                                                               |
| Progress via DynamoDB / SNS for client polling | `getWritable()` + named streams                                                                                                                                                                                                                     | Stream durable updates; clients read from the stream.                                                    |

<Callout type="info">
  `.waitForTaskToken` becomes `createHook()` or `createWebhook()`. Choice states become `if`/`else`. Map states become `Promise.all()`. Retry policies move from per-state configuration to step-level defaults.
</Callout>

## Translate your first workflow

Start with a single Task state. In ASL, even "call one Lambda" requires a state machine shell:

```json title="stateMachine.asl.json (Step Functions)"
"LoadOrder": {
  "Type": "Task",
  "Resource": "arn:aws:states:::lambda:invoke",
  "Parameters": { "FunctionName": "loadOrder", "Payload.$": "$" },
  "End": true
}
```

<Callout type="info">
  Examples use JSONPath mode. If your state machine sets `QueryLanguage: 'JSONata'`, the shape of `Arguments`/`Output` fields differs but the TypeScript translation is identical.
</Callout>

```typescript title="workflow/workflows/order.ts (Workflow SDK)"
export async function processOrder(orderId: string) {
  'use workflow'; // [!code highlight]
  return await loadOrder(orderId);
}

async function loadOrder(orderId: string) {
  'use step'; // [!code highlight]
  const res = await fetch(`https://example.com/api/orders/${orderId}`);
  return res.json() as Promise<{ id: string }>;
}
```

What changed: the ASL state machine and its Lambda collapse into two directive-tagged functions in one file.

### Adding a second step

In ASL, a second Task means a new state and a `"Next"` transition. In the Workflow SDK, it's another `await`:

```typescript
export async function processOrder(orderId: string) {
  'use workflow';
  const order = await loadOrder(orderId);
  await reserveInventory(order.id); // [!code highlight]
  return { orderId: order.id, status: 'reserved' };
}
```

`await` replaces `"Next"`. Each new step is a new function with `"use step"`; no additional deployment. The second version also reshapes the return value; the workflow return type can be anything serializable.

### Starting from an API route

Step Functions starts a run via `StartExecution` (AWS SDK or API Gateway integration). The Workflow SDK starts a run with `start()` from a route handler:

```typescript title="app/api/orders/route.ts"
import { start } from 'workflow/api';
import { processOrder } from '@/workflows/order';

export async function POST(request: Request) {
  const { orderId } = (await request.json()) as { orderId: string };
  const run = await start(processOrder, [orderId]); // [!code highlight]
  return Response.json({ runId: run.runId });
}
```

### Waiting for a fixed duration

A `Wait` state becomes `sleep()`:

```json title="stateMachine.asl.json (Step Functions)"
{ "Type": "Wait", "Seconds": 60, "Next": "Next" }
```

{/* @skip-typecheck: one-line snippet fragment */}

```typescript title="workflow/workflows/order.ts (Workflow SDK)"
await sleep('1m');
```

## Wait for an external signal

The minimal ASL for a callback is a Task with `.waitForTaskToken`:

```json title="approval.asl.json (Step Functions)"
"WaitForApproval": {
  "Type": "Task",
  "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken",
  "Parameters": {
    "QueueUrl": "https://sqs.us-east-1.amazonaws.com/123456789012/approvals",
    "MessageBody": {
      "refundId.$": "$.refundId",
      "TaskToken.$": "$$.Task.Token"
    }
  },
  "End": true
}
```

```typescript title="workflow/workflows/refund.ts (Workflow SDK)"
import { createHook } from 'workflow';

export async function refundWorkflow(refundId: string) {
  'use workflow';
  using approval = createHook<{ approved: boolean }>({ // [!code highlight]
    token: `refund:${refundId}:approval`,
  });
  return await approval;
}
```

What changed: no SQS queue, no task token, no callback Lambda. The hook suspends the workflow durably until it is resumed.

### Resuming the hook

Step Functions resumes by calling `SendTaskSuccess` with the task token. The Workflow SDK resumes by calling `resumeHook` with the hook's token:

```typescript title="app/api/refunds/[refundId]/approve/route.ts"
import { resumeHook } from 'workflow/api';

export async function POST(req: Request, { params }: { params: Promise<{ refundId: string }> }) {
  const { refundId } = await params;
  const { approved } = (await req.json()) as { approved: boolean };
  await resumeHook(`refund:${refundId}:approval`, { approved }); // [!code highlight]
  return Response.json({ ok: true });
}
```

### Branching on the result

In ASL, branching after the wait requires a Choice state. In TypeScript, it's just `if`/`else`:

```json title="approval.asl.json (Step Functions)"
"CheckApproval": {
  "Type": "Choice",
  "Choices": [
    { "Variable": "$.approved", "BooleanEquals": true, "Next": "Approved" }
  ],
  "Default": "Rejected"
}
```

{/* @skip-typecheck: continuation snippet */}

```typescript title="workflow/workflows/refund.ts (Workflow SDK)"
const { approved } = await approval;
if (approved) return { refundId, status: 'approved' }; // [!code highlight]
return { refundId, status: 'rejected' };
```

## Spawn a child workflow

In ASL, a parent machine calls `StartExecution` (usually via `.sync` or `.waitForTaskToken`) to launch a child. In the Workflow SDK, `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions. Returning the `Run` object from the spawn step lets workflow observability deep-link to the child run.

### Parent starts a child

```typescript title="workflow/workflows/parent.ts"
import { start } from 'workflow/api';

async function spawnChild(item: string) {
  'use step'; // [!code highlight]
  return await start(childWorkflow, [item]);
}

export async function parentWorkflow(item: string) {
  'use workflow';
  const run = await spawnChild(item);
  return { childRunId: run.runId };
}
```

### Awaiting the child's result

Add a second step that wraps `getRun()` and awaits `returnValue`:

```typescript
import { getRun } from 'workflow/api';

async function collectResult(runId: string) {
  'use step'; // [!code highlight]
  const run = getRun(runId);
  return (await run.returnValue) as { item: string; result: string };
}
```

Then in the workflow: `const result = await collectResult(run.runId);`. The child workflow itself (`childWorkflow`) is defined elsewhere with `"use workflow"`.

## What you stop operating

Moving off Step Functions removes these surfaces from the application:

* ASL state machine JSON and its reference syntax.
* Per-task Lambda functions, their IAM roles, and CloudFormation/CDK wiring.
* Task-token delivery infrastructure (SQS queues, callback Lambdas).
* Separate progress channels (DynamoDB, SNS) for client-visible updates.
* Remove CloudWatch and X-Ray wiring that was specific to orchestrator state transitions. Keep (or re-wire) any application-level CloudWatch alarms, log retention policies, or X-Ray propagation that the rest of your AWS footprint still depends on. Workflow SDK exports OTEL traces, so existing OTEL-compatible backends can continue to ingest them.

Workflow and step functions live in the same deployment as the application. State transitions are ordinary control flow (`await`, `if`, `Promise.all`, `for`). Progress streaming, retries, and observability are built in.

### What you take on

Steps that previously invoked AWS services via optimized integrations (EventBridge, DynamoDB, Bedrock, ECS.RunTask.sync, etc.) become ordinary SDK calls inside `'use step'` functions. Credentials and retries move into the step, and `.sync`-style waits for long-running jobs become explicit polling loops or hook-based callbacks.

## Step-by-step first migration

Pick one state machine and migrate it end-to-end before touching the rest. The steps below describe the smallest viable path.

### Step 1: Install the Workflow SDK

Add the `workflow` runtime package.

```bash
pnpm add workflow
```

### Step 2: Rewrite the state machine as a `"use workflow"` function

Transitions become `await` calls. Control flow (`Choice`, `Wait`, `Parallel`, `Map`) becomes `if`/`switch`, `sleep`, `Promise.all`, and loops.

```ts title="workflows/order.ts"
export async function processOrder(orderId: string) {
  "use workflow"; // [!code highlight]
  const order = await loadOrder(orderId);
  if (order.total > 1000) await reviewManually(order);
  await chargePayment(order);
}
```

### Step 3: Move each Lambda into a step function

Inline the Lambda body into a function with `"use step"` on the first line. Step functions keep full Node.js access, so existing SDK calls work unchanged.

```ts
async function loadOrder(id: string) {
  "use step"; // [!code highlight]
  return fetch(`/api/orders/${id}`).then((r) => r.json());
}
```

### Step 4: Replace `.waitForTaskToken` with a hook

Swap the task-token callback Lambda for `createHook()`. Callers `resumeHook(token, payload)` instead of `SendTaskSuccess`.

Move Retry/Catch off per-state configuration and onto step boundaries. Set `maxRetries` as a function property; throw `RetryableError` or `FatalError` to control retry behavior:

```typescript
async function chargePayment(orderId: string) {
  "use step";
  // ...
}
chargePayment.maxRetries = 5;
```

See [`/docs/foundations/errors-and-retries`](/docs/foundations/errors-and-retries) for the full retry and SAGA compensation patterns.

### Step 5: Start runs from an API route

Delete the `StartExecution` call and IAM wiring. Launch runs directly from a route handler:

```ts title="app/api/orders/route.ts"
import { start } from "workflow/api";
import { processOrder } from "@/workflows/order";

export async function POST(req: Request) {
  const { orderId } = await req.json();
  const run = await start(processOrder, [orderId]);
  return Response.json({ runId: run.runId });
}
```

### Step 6: Retire the Step Functions infrastructure

Delete the ASL JSON, per-task Lambda deployments, IAM roles, and callback queues. Remove CloudWatch and X-Ray wiring that was specific to orchestrator state transitions — keep alarms, log retention, and traces for resources you still depend on. Verify the run in `npx workflow web` before shipping.

## Features without a 1:1 equivalent

* **Express workflows.** At-least-once semantics and 5-minute duration make them a poor fit for the SDK's durable replay model. Consider keeping them on Step Functions or migrating to a queue consumer.
* **Distributed Map state.** Up to 10,000 concurrent child executions with S3 item sources has no 1:1 analog; fan out with step-wrapped `start()` per item, then `Promise.all` with `p-limit` to bound concurrency.
* **Optimized AWS service integrations (`arn:aws:states:::dynamodb:*`, `eventbridge:*`, `bedrock:*`, `ecs:runTask.sync`, etc.).** These become regular SDK calls inside `'use step'` functions — credentials, retries, and polling move into the step.
* **Per-state IAM roles.** ASL lets each state run under its own IAM role. In the SDK, all steps share the deployment's credentials; scope secrets and roles at deployment time.
* **CloudWatch alarms / X-Ray cross-service traces / CloudWatch Logs retention.** The SDK event log + observability UI replaces orchestrator state transitions, not AWS-wide observability. Keep alarms and traces for other resources.
* **`JSONata` `QueryLanguage` mode.** Valid at the source; the TS translation is identical regardless of mode.

## Quick-start checklist

* Replace the ASL state machine with a single `"use workflow"` function. Transitions become `await` calls.
* Convert each Task / Lambda into a `"use step"` function in the same file.
* Replace Choice states with `if`/`else`/`switch`.
* Replace Wait states with `sleep()` from `workflow`.
* Replace Parallel states with `Promise.all()`.
* Replace Map states based on their concurrency mode: inline sequential → `for` loop; bounded parallel (`MaxConcurrency: N`) → batched `Promise.all` or a concurrency limiter like `p-limit`; Distributed Map / large fan-out → step-wrapped `start()` per item, then step-wrapped `getRun()` to collect.
* Replace `StartExecution` child machines with `"use step"` wrappers around `start()` and `getRun()`.
* Replace `.waitForTaskToken` with `createHook()` (internal callers) or `createWebhook()` (HTTP callers).
* Move Retry/Catch to step boundaries using `maxRetries`, `RetryableError`, and `FatalError`.
* Use `getStepMetadata().stepId` as the idempotency key for external side effects.
* Stream progress from steps with `getWritable()` instead of polling DynamoDB or SNS.
* Deploy and verify runs end-to-end with built-in observability.

***

*Verified against `workflow@5.0.0-beta.1` and the AWS Step Functions Amazon States Language spec on 2026-04-16.*


---
title: Migrating from Inngest
description: Move an Inngest TypeScript SDK v3 or v4 app to the Workflow SDK by replacing createFunction, step.run(), step.sleep(), step.waitForEvent(), and step.invoke() with Workflows, Steps, Hooks, and start()/getRun().
type: guide
summary: Translate an Inngest app into the Workflow SDK with side-by-side code examples.
prerequisites:
  - /docs/getting-started/next
  - /docs/foundations/workflows-and-steps
related:
  - /docs/foundations/starting-workflows
  - /docs/foundations/errors-and-retries
  - /docs/foundations/hooks
  - /docs/foundations/streaming
  - /docs/deploying/world/vercel-world
---

# Migrating from Inngest



<Callout type="info">
  Install the Workflow SDK migration skill:

  ```bash
  npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk
  ```
</Callout>

## Why migrate to the Workflow SDK

* Streaming is built in. Durable progress writes go to named streams via `getWritable()`. There is no separate realtime publish channel or WebSocket layer to operate.
* Infrastructure and orchestration live in a single deployment. Workflows run where the app runs. There is no separate Inngest Dev Server or event bus to operate.
* TypeScript-first DX. Steps are named async functions marked with `"use step"`. No inline closures tied to a framework-specific lifecycle.
* Agent-first tooling: the `npx workflow` CLI, `@workflow/ai` integration for durable AI agents, and a Claude skill for generating workflows.

Inngest code samples in this guide use the v4 two-argument `createFunction({ id, triggers }, handler)` shape. If the app is still on Inngest v3, treat the three-argument form `createFunction({ id }, { event: '...' }, handler)` as equivalent — the `triggers` option in v4 replaces the second argument in v3.

## What changes when you leave Inngest

Inngest defines functions with `inngest.createFunction()`, registers them through a `serve()` handler, and breaks work into steps with `step.run()`, `step.sleep()`, and `step.waitForEvent()`. The platform routes events, schedules steps, and applies retries.

The Workflow SDK replaces that with `"use workflow"` functions that orchestrate `"use step"` functions in plain TypeScript. There is no function registry, event dispatch layer, or SDK client. Durable replay, automatic retries, and step-level persistence are built into the runtime.

Migration collapses the SDK abstraction into plain async functions. Business logic stays the same.

<Callout type="info">
  Inngest's event-bus model is loosely coupled — publishers don't know consumers. `start()` requires the caller to import the workflow function directly, giving stronger type safety but tighter coupling. For event-bus-like fan-out, wrap `start()` in a shared publisher module.
</Callout>

## Concept mapping

| Inngest                              | Workflow SDK                                        | Migration note                                                                                                       |
| ------------------------------------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `inngest.createFunction()`           | `"use workflow"` function started with `start()`    | No wrapper needed.                                                                                                   |
| `step.run()`                         | `"use step"` function                               | Standalone async function with Node.js access.                                                                       |
| `step.sleep()` / `step.sleepUntil()` | `sleep()`                                           | `sleep('5m')` for a duration; `sleep(date)` for sleep-until.                                                         |
| `step.waitForEvent()`                | `createHook()` or `createWebhook()`                 | Hooks for typed signals, webhooks for HTTP.                                                                          |
| `step.invoke()`                      | `"use step"` wrappers around `start()` / `getRun()` | Spawn a child run, pass `runId` forward.                                                                             |
| `inngest.send()` / event triggers    | `start()` from your app boundary                    | Start workflows directly.                                                                                            |
| Retry configuration (`retries`)      | `RetryableError`, `FatalError`, `maxRetries`        | Retry logic lives at the step level.                                                                                 |
| `step.sendEvent()`                   | `"use step"` wrapper around `start()`               | Fan out via `start()`, not an event bus.                                                                             |
| Realtime / `step.realtime.publish()` | `getWritable()` / `getWritable({ namespace })`      | Named streams are the canonical way for clients to read workflow status. No database or `getRun()` polling required. |

## Translate your first workflow

Start with the shell of a function. Inngest wraps it in `createFunction`; Workflow SDK marks it with a directive.

{/* @skip-typecheck: Inngest SDK types not available */}

```typescript title="inngest/functions/order.ts"
export const processOrder = inngest.createFunction(
  {
    id: 'process-order',
    triggers: { event: 'order/created' },
  },
  async ({ event, step }) => {
    return { orderId: event.data.orderId, status: 'completed' };
  }
);
```

```typescript title="workflow/workflows/order.ts"
export async function processOrder(orderId: string) {
  'use workflow'; // [!code highlight]
  return { orderId, status: 'completed' };
}
```

**What changed:** the factory + event binding collapses into a plain exported function with a `"use workflow"` directive.

### Add a step

`step.run()` closures become named `"use step"` functions.

```typescript title="workflow/workflows/order.ts"
async function loadOrder(orderId: string) {
  'use step'; // [!code highlight]
  const res = await fetch(`https://example.com/api/orders/${orderId}`);
  return res.json() as Promise<{ id: string }>;
}
```

Call it from the workflow like any async function: `const order = await loadOrder(orderId)`. Additional side effects (`reserveInventory`, `chargePayment`) follow the same shape.

### Start the run

Inngest dispatches via `inngest.send({ name: 'order/created', data: { orderId } })`. Workflow SDK launches directly:

```typescript title="app/api/orders/route.ts"
import { start } from 'workflow/api';
import { processOrder } from '@/workflows/order';

const run = await start(processOrder, [orderId]); // [!code highlight]
```

No event bus, no registry. `start()` returns a handle immediately.

## Wait for an external signal

`step.waitForEvent()` becomes `createHook()` plus `await`.

```typescript title="workflow/workflows/refund.ts (Inngest)"
const approval = await step.waitForEvent('wait-for-approval', {
  event: 'refund/approved',
  match: 'data.refundId',
  timeout: '7d',
});
```

{/* @skip-typecheck: snippet without imports */}

```typescript title="workflow/workflows/refund.ts (Workflow SDK)"
using approval = createHook<{ approved: boolean }>({ // [!code highlight]
  token: `refund:${refundId}:approval`,
});
const payload = await approval;
```

**What changed:** event name + match expression collapse into a single `token` string. The caller supplies that token directly, with no event schema.

### Resume from an API route

Inngest resumes with `inngest.send({ name: 'refund/approved', data: { refundId, approved } })`. The SDK equivalent is `resumeHook`:

```typescript title="app/api/refunds/[refundId]/approve/route.ts"
import { resumeHook } from 'workflow/api';

export async function POST(request: Request, { params }: { params: Promise<{ refundId: string }> }) {
  const { refundId } = await params;
  const { approved } = (await request.json()) as { approved: boolean };
  await resumeHook(`refund:${refundId}:approval`, { approved }); // [!code highlight]
  return Response.json({ ok: true });
}
```

### Add a timeout and branch on the payload

Inngest's `timeout: '7d'` option maps to a `Promise.race()` with `sleep()`:

{/* @skip-typecheck: continuation snippet */}

```typescript title="workflow/workflows/refund.ts"
const result = await Promise.race([
  approval.then((p) => ({ type: 'decision' as const, approved: p.approved })),
  sleep('7d').then(() => ({ type: 'timeout' as const })), // [!code highlight]
]);

if (result.type === 'timeout') return { refundId, status: 'timed-out' };
if (!result.approved) return { refundId, status: 'rejected' };
return { refundId, status: 'approved' };
```

<Callout type="info">
  Event matching disappears. A hook's token encodes the routing (for example, `refund:${refundId}:approval`), and the caller supplies that token to `resumeHook()`.
</Callout>

## Spawn a child workflow

`step.invoke()` splits into two steps: spawn and collect. `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions. Return the `Run` object from the spawn step so observability can deep-link into the child run.

You can return either the full `Run` object (enables deep-linking) or just `run.runId` (simpler).

{/* @skip-typecheck: snippet without imports */}

```typescript title="workflow/workflows/parent.ts"
async function spawnChild(item: string) {
  'use step';
  return start(childWorkflow, [item]); // [!code highlight]
}
```

Await the result in a second step, then orchestrate both from the parent:

{/* @skip-typecheck: snippet without imports */}

```typescript title="workflow/workflows/parent.ts"
async function collectResult(runId: string) {
  'use step';
  const run = getRun(runId);
  return await run.returnValue; // [!code highlight]
}

export async function parentWorkflow(item: string) {
  'use workflow';
  const child = await spawnChild(item);
  return await collectResult(child.runId);
}
```

## What you stop operating

Dropping the Inngest SDK removes several moving parts:

* **No SDK client or serve handler.** Workflow files carry directive annotations. No registry, no serve endpoint.
* **No event bus.** `start()` launches workflows directly from API routes, server actions, or other entry points. No event schemas or dispatch layer.
* **No inline step closures.** Steps are named async functions. They type-check and test like any other TypeScript function.
* **No separate streaming transport.** `getWritable()` delivers progress to clients without WebSockets or SSE glue.
* **No idle workers.** Workflows suspended on `sleep()` or a hook consume no compute until resumed.

## Step-by-step first migration

Pick one Inngest function and migrate it end-to-end before touching the rest. The steps below describe the smallest viable path.

### Step 1: Install the Workflow SDK

Install the SDK. Framework integrations (`workflow/next`, `workflow/nitro`, `workflow/nuxt`, etc.) are subpath exports of the same `workflow` package — no additional install needed.

```bash
pnpm add workflow
```

### Step 2: Convert `createFunction` to a `"use workflow"` export

Replace the factory call with a plain async export. Move the handler body up. The event-binding argument goes away.

```ts title="workflows/order.ts"
// Before (Inngest)
// export const processOrder = inngest.createFunction(
//   { id: "process-order", triggers: { event: "order/created" } },
//   async ({ event, step }) => { ... }
// );

// After (Workflow SDK)
export async function processOrder(orderId: string) {
  "use workflow"; // [!code highlight]
  // ...
}
```

### Step 3: Convert `step.run` callbacks into named step functions

Each inline callback becomes a named function with `"use step"` on the first line. The workflow calls them with a plain `await`.

```ts
async function loadOrder(id: string) {
  "use step"; // [!code highlight]
  return fetch(`https://example.com/api/orders/${id}`).then((r) => r.json());
}
```

Configure per-step retry counts by assigning `maxRetries` as a function property:

```ts
async function callApi(endpoint: string) {
  "use step";
  const response = await fetch(endpoint);
  return response.json();
}
callApi.maxRetries = 5;
```

See [Errors and retries](/docs/foundations/errors-and-retries) for full retry docs.

### Step 4: Replace `waitForEvent`, `sleep`, and `invoke`

* `step.waitForEvent(...)` → `createHook({ token })` + `await hook`. Resume it from an API route with `resumeHook(token, payload)`.
* `step.sleep(...)` → `sleep("5m")` from `workflow`.
* `step.invoke(child, { data })` → wrap `start(child, [data])` in a `"use step"` function that returns the `Run`, and optionally read its return value with `getRun(run.runId).returnValue`.

### Step 5: Start runs from the app

Delete the `serve()` handler and event dispatch. Launch runs directly from an API route:

```ts title="app/api/orders/route.ts"
import { start } from "workflow/api";
import { processOrder } from "@/workflows/order";

export async function POST(req: Request) {
  const { orderId } = await req.json();
  const run = await start(processOrder, [orderId]);
  return Response.json({ runId: run.runId });
}
```

### Step 6: Retire the Inngest infrastructure

Remove the `inngest` client, the `serve()` route, event schemas, and the Inngest Dev Server from the app. Verify the run in `npx workflow web` before shipping.

## Features without a 1:1 equivalent

* **Cron / scheduled functions (`triggers: { cron: '...' }`).** The SDK has no built-in scheduler. Trigger runs from Vercel Cron or a system cron calling `start()` from an API route.
* **Concurrency, throttling, rate limiting, debounce, singleton, priority, and `batchEvents`.** These function-level settings have no direct analog. Enforce limits inside steps (semaphores, external rate-limiter service) or debounce at the publisher before calling `start()`.
* **`EventSchemas` / typed events.** The event-bus indirection goes away; publishers import the workflow function directly, giving the same type safety through a different mechanism.
* **Event-bus fan-out by name match.** `inngest.send()` that triggered multiple functions by event name must be replaced by explicit `start()` calls for each target workflow.

## Quick-start checklist

* Replace `inngest.createFunction()` with a `"use workflow"` function; launch it with `start()`.
* Convert each `step.run()` callback into a named `"use step"` function.
* Swap `step.sleep()` / `step.sleepUntil()` for `sleep()` from `workflow`.
* Swap `step.waitForEvent()` for `createHook()` (internal) or `createWebhook()` (HTTP).
* Model `waitForEvent` timeouts as `Promise.race()` between the hook and `sleep()`.
* Replace `step.invoke()` with `"use step"` wrappers around `start()` and `getRun()`.
* Replace `step.sendEvent()` fan-out with `start()` called from a `"use step"` function.
* Remove the Inngest client, `serve()` handler, and event definitions.
* Push retry configuration down to step boundaries via `maxRetries`, `RetryableError`, and `FatalError`.
* Use `getStepMetadata().stepId` as the idempotency key for external side effects.
* Replace `step.realtime.publish()` with `getWritable()`.
* Deploy and verify end-to-end with the built-in observability UI.

***

*Verified against `workflow@5.0.0-beta.1` and Inngest TypeScript SDK v4 on 2026-04-16.*


---
title: Migrating from Temporal
description: Move a Temporal TypeScript workflow to the Workflow SDK by replacing Activities, Workers, Signals, and Child Workflows with Workflows, Steps, Hooks, and start()/getRun().
type: guide
summary: Translate a Temporal app into the Workflow SDK with side-by-side code examples.
prerequisites:
  - /docs/getting-started/next
  - /docs/foundations/workflows-and-steps
related:
  - /docs/foundations/starting-workflows
  - /docs/foundations/errors-and-retries
  - /docs/foundations/hooks
  - /docs/foundations/streaming
  - /docs/deploying/world/vercel-world
---

# Migrating from Temporal



<Callout type="info">
  Install the Workflow SDK migration skill:

  ```bash
  npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk
  ```
</Callout>

## Why migrate to the Workflow SDK

* Streaming is built in. Durable progress writes go to named streams via `getWritable({ namespace })`, and clients read them directly. No separate WebSocket, SSE, or progress-polling layer to operate.
* Infrastructure and orchestration live in a single deployment. There is no separate Worker fleet or Temporal Server to run. The runtime, step logic, and orchestration share the app's observability and log aggregation.
* TypeScript-first developer experience. Workflows and steps live in the same file with plain `await` control flow, `try/catch`, and `Promise.all`.
* Agent-first tooling. A first-class CLI (`npx workflow`), [`@workflow/ai` integration for durable AI agents](/docs/ai), and a bundled Claude skill for AI-assisted authoring.
* Per-step retry controls. `RetryableError`, `FatalError`, and `maxRetries` live at the step boundary instead of an Activity-level retry policy configured elsewhere.

## What changes when you leave Temporal

Temporal requires operating a control plane (Temporal Server or Cloud), a Worker fleet, Activity modules wired through `proxyActivities`, and Task Queues. The workflow code is durable; the surrounding infrastructure is substantial.

The Workflow SDK runs on managed infrastructure. Write `"use workflow"` functions that orchestrate `"use step"` functions in the same file, in plain TypeScript. There are no Workers, Task Queues, or separate Activity modules. Durable replay, automatic retries, and event history are handled by the runtime.

Workflow functions must still be deterministic — no `Date.now()`, `Math.random()`, direct network I/O, or wall-clock branches inside `"use workflow"`. Move any such logic into a step, as you do today with Activities.

Migration removes infrastructure and collapses indirection. Business logic stays as regular async TypeScript.

## Concept mapping

| Temporal                                 | Workflow SDK                                               | Migration note                                                                                                                                                                                                                                                 |
| ---------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Workflow Definition / Workflow Execution | `"use workflow"` function / run started with `start()`     | Keep orchestration code in the workflow function.                                                                                                                                                                                                              |
| Activity                                 | `"use step"` function                                      | Put side effects and Node.js access in steps.                                                                                                                                                                                                                  |
| Worker + Task Queue                      | Managed execution                                          | No worker fleet or polling loop to operate.                                                                                                                                                                                                                    |
| Signal                                   | `createHook()` or `createWebhook()`                        | Use hooks for typed resume signals; webhooks for HTTP callbacks.                                                                                                                                                                                               |
| Query                                    | `getWritable({ namespace: 'status' })` stream              | Durably stream status updates from the workflow. Clients read from the stream instead of polling a database.                                                                                                                                                   |
| Update                                   | `createHook()` + `resumeHook()` (one-way)                  | Temporal Updates return a value to the caller; hooks do not. If the Update returns data, either write the result to a named stream via `getWritable()` and have the caller read from it, or keep an HTTP read route that fetches the workflow's current state. |
| Child Workflow                           | `"use step"` wrappers around `start()` / `getRun()`        | Spawn from a step and return the `Run` object so observability can deep-link into child runs.                                                                                                                                                                  |
| Activity retry policy                    | Step retries, `RetryableError`, `FatalError`, `maxRetries` | Retries live at the step boundary.                                                                                                                                                                                                                             |
| Event History                            | Workflow event log / run timeline                          | Same durable replay; built-in observability UI replaces Temporal Web. Search attributes and visibility APIs have no direct equivalent — filter by run status and timestamps instead.                                                                           |

## Translate your first workflow

### Minimal translation

Start with the directive change. The Temporal definition proxies activities through a module; the Workflow SDK version puts the directive inline.

{/* @skip-typecheck: Temporal SDK types not available */}

```typescript title="workflows/order.ts (Temporal)"
import * as wf from '@temporalio/workflow';
import type * as activities from './activities';

const { chargePayment } = wf.proxyActivities<typeof activities>({
  startToCloseTimeout: '5 minutes',
});

export async function processOrder(orderId: string) {
  await chargePayment(orderId);
  return { orderId, status: 'completed' };
}
```

```typescript title="workflows/order.ts (Workflow SDK)"
export async function processOrder(orderId: string) {
  'use workflow'; // [!code highlight]
  await chargePayment(orderId);
  return { orderId, status: 'completed' };
}
```

**What changed:** `proxyActivities` and the activity module disappear. The orchestrator is plain async TypeScript marked with `"use workflow"`.

### Adding a step

Side effects move into a colocated `"use step"` function:

```typescript title="workflow/workflows/order.ts"
async function chargePayment(orderId: string) {
  'use step'; // [!code highlight]
  await fetch(`https://example.com/api/orders/${orderId}/charge`, {
    method: 'POST',
  });
}
```

Additional steps (`loadOrder`, `reserveInventory`) follow the same shape and can be called from the workflow in sequence.

### Starting the run

Replace Worker + Task Queue wiring with a single `start()` call from an API route:

```typescript title="app/api/orders/route.ts"
import { start } from 'workflow/api';
import { processOrder } from '@/workflows/order';

export async function POST(request: Request) {
  const { orderId } = (await request.json()) as { orderId: string };
  const run = await start(processOrder, [orderId]); // [!code highlight]
  return Response.json({ runId: run.runId });
}
```

## Wait for an external signal

### Minimal translation

Temporal needs a signal definition, a handler, and a `condition()` guard. The Workflow SDK collapses all three into a single `createHook()` + `await`.

{/* @skip-typecheck: Temporal SDK types not available */}

```typescript title="temporal/workflows/refund.ts"
export const approveRefund = wf.defineSignal<[boolean]>('approveRefund');

export async function refundWorkflow(refundId: string) {
  let approved: boolean | undefined;
  wf.setHandler(approveRefund, (v) => { approved = v; });
  await wf.condition(() => approved !== undefined);
  return { refundId, approved };
}
```

```typescript title="workflow/workflows/refund.ts"
import { createHook } from 'workflow';

export async function refundWorkflow(refundId: string) {
  'use workflow';
  using approval = createHook<{ approved: boolean }>({
    token: `refund:${refundId}:approval`, // [!code highlight]
  });
  const { approved } = await approval; // [!code highlight]
  return { refundId, approved };
}
```

The workflow suspends durably at `await approval` until resumed. No polling, no handler registration.

Requires TypeScript 5.2+ for the `using` keyword. On older TypeScript, assign the hook to a `const` and call `resumeHook()` from the consuming code path.

### Resuming from an API route

Any HTTP caller can resume by token:

```typescript title="app/api/refunds/[refundId]/approve/route.ts"
import { resumeHook } from 'workflow/api';

export async function POST(request: Request, { params }: { params: Promise<{ refundId: string }> }) {
  const { refundId } = await params;
  const body = (await request.json()) as { approved: boolean };
  await resumeHook(`refund:${refundId}:approval`, body); // [!code highlight]
  return Response.json({ ok: true });
}
```

<Callout type="info">
  Temporal Queries expose in-memory workflow state on demand. In the Workflow SDK, the equivalent is a durable stream: call `getWritable({ namespace: 'status' })` from inside the workflow to write status updates, and have clients read from the end of that stream to get the current state. Hooks are the write channel for resuming a paused workflow with new data.
</Callout>

## Spawn a child workflow

### Minimal translation

`start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions. Return the `Run` object (not a plain `runId` string) so workflow observability can deep-link into child runs.

```typescript title="workflow/workflows/parent.ts"
import { start } from 'workflow/api';

async function spawnChild(item: string) {
  'use step'; // [!code highlight]
  return start(childWorkflow, [item]); // [!code highlight]
}

export async function parentWorkflow(item: string) {
  'use workflow';
  const child = await spawnChild(item); // [!code highlight]
  return { childRunId: child.runId };
}
```

### Awaiting the child's return value

A second step fetches the run and awaits `returnValue`:

```typescript title="workflow/workflows/parent.ts"
import { getRun } from 'workflow/api';

async function collectResult(runId: string) {
  'use step';
  const run = getRun(runId);
  return (await run.returnValue) as { item: string; result: string }; // [!code highlight]
}
```

Call both steps from the parent in sequence: `const result = await collectResult(child.runId)`. To fan out, call `spawnChild` inside a loop, then `Promise.all` the `collectResult` calls.

<Callout type="warn">
  Activity retry policy moves to the step boundary. Use `maxRetries`, `RetryableError`, and `FatalError` on each step instead of a single workflow-wide retry block.

  Temporal's per-activity timeouts (`startToCloseTimeout`, `scheduleToCloseTimeout`, `heartbeatTimeout`) have no direct Workflow SDK equivalent. Enforce per-step deadlines inside the step using `AbortSignal.timeout(ms)` (e.g. on `fetch`), or wrap the call from the workflow in `Promise.race(step(), sleep('5m'))` to bail out after a bounded duration.

  Temporal's retry policy knobs (`initialInterval`, `backoffCoefficient`, `maximumInterval`, `nonRetryableErrorTypes`) don't port 1:1 — only `maxRetries` is configurable at the step boundary. Classify retryability with `RetryableError` (retryable) and `FatalError` (terminal) instead of listing error types, and control the delay between attempts with `new RetryableError(msg, { retryAfter: '5s' })`.

  Set `maxRetries` as a property assignment on the step function:

  ```typescript
  async function chargePayment(orderId: string) {
    "use step";
    // ...
  }
  chargePayment.maxRetries = 5;
  ```

  See [/docs/foundations/errors-and-retries](/docs/foundations/errors-and-retries) for full retry docs.
</Callout>

## What you stop operating

* **Temporal Server or Cloud.** Durable state lives in the managed event log.
* **Worker fleet.** The runtime schedules execution; workflows run where the app runs.
* **Task Queues.** No queue routing to configure or monitor.
* **Activity modules and `proxyActivities`.** Steps live next to the workflow that calls them.
* **Custom progress transport.** `getWritable()` streams updates from steps.

Suspended workflows (on `sleep()` or a hook) consume no compute until resumed.

## Step-by-step first migration

Pick one Temporal workflow and migrate it end-to-end before touching the rest. The steps below describe the smallest viable path.

### Step 1: Install the Workflow SDK

Add the runtime. The Next.js integration ships as a subpath (`workflow/next`) of the same package.

```bash
pnpm add workflow
```

### Step 2: Collapse Activities into step functions

Delete the `activities/` module and the `proxyActivities` call. Each former Activity becomes a plain async function with `"use step"` on the first line, living next to the orchestrator.

```ts title="workflows/order.ts"
async function loadOrder(id: string) {
  "use step"; // [!code highlight]
  return fetch(`/api/orders/${id}`).then((r) => r.json());
}
```

### Step 3: Mark the orchestrator with `"use workflow"`

Keep the existing control flow: `await`, `try/catch`, `Promise.all`. The directive turns the function into a durable replay target.

```ts
export async function processOrder(orderId: string) {
  "use workflow"; // [!code highlight]
  const order = await loadOrder(orderId);
  // ...
}
```

### Step 4: Replace Signals with hooks

Swap `defineSignal` + `setHandler` for `createHook()`. Callers `resumeHook(token, payload)` instead of `handle.signal(signalDef, payload)` on a `WorkflowHandle` obtained from `client.workflow.getHandle(workflowId)`.

### Step 5: Start runs from an API route or server action

Delete the Worker bootstrap. Launch runs from an API route or server action with `start()`:

```ts title="app/api/orders/route.ts"
import { start } from "workflow/api";
import { processOrder } from "@/workflows/order";

export async function POST(req: Request) {
  const { orderId } = await req.json();
  const run = await start(processOrder, [orderId]);
  return Response.json({ runId: run.runId });
}
```

### Step 6: Retire the Temporal infrastructure

Remove the Worker process, `@temporalio/*` dependencies, and the Temporal Server or Cloud connection. Verify the run in the built-in observability UI (`npx workflow web`) before shipping.

## Features without a 1:1 equivalent

* **Search attributes / visibility queries.** Temporal's search attribute system has no direct analog. Filter runs by status and timestamps via `getRun()` / observability UI.
* **Event history archival.** Temporal archives histories to S3/GCS for long-term retention. Workflow SDK event logs are durable, but retention depends on the integration you are using. For example, see [Vercel Workflow Storage Retention](https://vercel.com/docs/workflows/pricing#storage-retention) for Vercel.
* **Per-activity timeouts (`startToCloseTimeout`, `scheduleToCloseTimeout`, `heartbeatTimeout`).** Implement deadlines inside the step with `AbortSignal.timeout(ms)`, or wrap the call in `Promise.race(step(), sleep(...))` from the workflow.
* **Rich retry policy (`initialInterval`, `backoffCoefficient`, `maximumInterval`, `nonRetryableErrorTypes`).** Only `maxRetries` is configurable. Classify retryability with `RetryableError`/`FatalError`; control delay between attempts via `new RetryableError(msg, { retryAfter: '5s' })`.
* **Workers + task queues.** Managed deployments replace workers; self-hosted deployments still need a `World` implementation (see [/docs/deploying/building-a-world](/docs/deploying/building-a-world)).

## Quick-start checklist

* Move orchestration into a `"use workflow"` function.
* Convert each Activity into a `"use step"` function.
* Remove Worker and Task Queue code. Start workflows from the app with `start()`.
* Replace Signals with `createHook()` or `createWebhook()` for HTTP callers.
* Wrap `start()` and `getRun()` in `"use step"` functions for child workflows. Return the `Run` object from `start()` so observability can deep-link into child runs.
* Set retry policy per step with `maxRetries`, `RetryableError`, and `FatalError`.
* Use `getStepMetadata().stepId` as the idempotency key for external side effects.
* Stream status and progress from steps with `getWritable({ namespace: 'status' })`, and have clients read from the stream instead of polling.
* Deploy the app and verify runs end-to-end in the built-in observability UI.

***

*Verified against `workflow@5.0.0-beta.1` and `@temporalio/workflow@1.16` on 2026-04-16.*


---
title: Migrating from trigger.dev
description: Move a trigger.dev v3 TypeScript app to the Workflow SDK by replacing task(), schemaTask(), wait.for / wait.forToken, triggerAndWait, and metadata streams with Workflows, Steps, Hooks, and start() / getRun().
type: guide
summary: Translate a trigger.dev v3 app into the Workflow SDK with side-by-side code examples.
prerequisites:
  - /docs/getting-started/next
  - /docs/foundations/workflows-and-steps
related:
  - /docs/foundations/starting-workflows
  - /docs/foundations/errors-and-retries
  - /docs/foundations/hooks
  - /docs/foundations/streaming
  - /docs/deploying/world/vercel-world
---

# Migrating from trigger.dev



<Callout type="info">
  Install the Workflow SDK migration skill:

  ```bash
  npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk
  ```
</Callout>

## Why migrate to the Workflow SDK?

* Streaming is built in via named streams (`getWritable()` and `getWritable({ namespace })`). Durable status writes replace `metadata.stream()` and `metadata.set()`, and clients read from the end of the stream for current status.
* Orchestration and infrastructure live in a single deployment. There is no separate trigger.dev cloud or self-hosted worker fleet to operate.
* TypeScript-first DX: plain async/await control flow, no `task()` factory, no `schemaTask()` wrapper for payloads you already type with TypeScript.
* Agent-first tooling: the `npx workflow` CLI, `@workflow/ai` for durable AI agents, and a Claude skill for generating workflows.
* Retry policy is per step. Throw `RetryableError` to retry with a delay, or `FatalError` to stop. There is no central task-level retry config.

## What changes when you leave trigger.dev?

trigger.dev v3 defines durable work with `task()` or `schemaTask()` from `@trigger.dev/sdk` (trigger.dev v3), deploys tasks to the trigger.dev cloud or a self-hosted instance, and triggers runs via `tasks.trigger()`. A separate worker fleet picks up runs, applies retry policies, and routes `wait.for`, `wait.forToken`, and `metadata.stream` calls through the platform.

The Workflow SDK replaces that with `"use workflow"` functions that orchestrate `"use step"` functions in plain TypeScript. There is no task registry, separate deploy target, or SDK client. Durable replay, retries, and event history ship with the runtime.

Migration collapses the task abstraction into plain async functions. Business logic stays the same.

## Concept mapping

| trigger.dev                                                                  | Workflow SDK                                       | Migration note                                                                                                                                                                                                                                                                                |
| ---------------------------------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `task({ id, run })`                                                          | `"use workflow"` function started with `start()`   | No factory or id registry.                                                                                                                                                                                                                                                                    |
| `schemaTask({ schema, run })`                                                | Typed function + `"use workflow"`                  | Validate inputs at the call site.                                                                                                                                                                                                                                                             |
| Inline `run` body                                                            | `"use step"` function                              | Side effects move into named steps.                                                                                                                                                                                                                                                           |
| `logger` / `metadata.set`                                                    | `console` + `getWritable({ namespace: 'status' })` | Logs flow through the run timeline. Status writes go on a named stream.                                                                                                                                                                                                                       |
| `wait.for({ seconds \| minutes \| hours \| days })` / `wait.until({ date })` | `sleep()`                                          | Import from `workflow`.                                                                                                                                                                                                                                                                       |
| `wait.forToken({ timeout })`                                                 | `createHook()` + `Promise.race` with `sleep()`     | Hooks carry a typed token.                                                                                                                                                                                                                                                                    |
| `tasks.trigger()` / `triggerAndWait()`                                       | `start()` and `getRun(runId).returnValue`          | Wrap both in `"use step"` functions.                                                                                                                                                                                                                                                          |
| `batch.triggerAndWait()`                                                     | `Promise.all(runIds.map(collectResult))`           | Fan out via standard concurrency.                                                                                                                                                                                                                                                             |
| `AbortTaskRunError`                                                          | `FatalError`                                       | Stops retries immediately.                                                                                                                                                                                                                                                                    |
| `retry.onThrow` / `retry.fetch`                                              | `RetryableError`, `FatalError`, `maxRetries`       | Retry count lives on the step via `myStep.maxRetries = N` (default 3). Control delay between attempts by throwing `new RetryableError(msg, { retryAfter: '5s' })` — there is no built-in exponential helper; compute the delay yourself based on `getStepMetadata().attempt` if you need one. |
| `metadata.stream()` / Realtime                                               | `getWritable()` / `getWritable({ namespace })`     | For granular business status (progress updates, current-stage messages), prefer writing to `getWritable({ namespace: 'status' })` from a step and reading from the end of the named stream on the client. Use `getRun(runId).status` for terminal/lifecycle state only.                       |
| Self-hosted worker + dashboard                                               | Managed execution + built-in UI                    | No worker fleet to operate.                                                                                                                                                                                                                                                                   |

## Translate your first workflow

<Callout type="warn">
  trigger.dev's `task.run` body has full Node.js access. The SDK's `'use workflow'` body runs in a sandboxed VM — side effects (I/O, `Date.now()`, `Math.random()`, DB, fetch) must live inside `'use step'` functions. Orchestration stays in the workflow body.
</Callout>

Start with the shell. trigger.dev wraps the handler in `task()`; the Workflow SDK marks the function with a directive.

```typescript title="trigger/order.ts (trigger.dev)"
import { task } from '@trigger.dev/sdk';

export const processOrder = task({
  id: 'process-order',
  run: async (payload: { orderId: string }) => {
    return { orderId: payload.orderId, status: 'completed' };
  },
});
```

```typescript title="trigger/order.ts (Workflow SDK)"
export async function processOrder(orderId: string) {
  'use workflow'; // [!code highlight]
  return { orderId, status: 'completed' };
}
```

**What changed:** the `task()` factory and its `id` field disappear. The function is a plain export tagged with `"use workflow"`.

### Add a step

The body of `run` becomes one or more `"use step"` functions.

```typescript title="workflow/workflows/order.ts"
async function loadOrder(orderId: string) {
  'use step'; // [!code highlight]
  const res = await fetch(`https://example.com/api/orders/${orderId}`);
  return res.json() as Promise<{ id: string }>;
}
```

Call it from the workflow with a plain `await`. Each additional side effect (`reserveInventory`, `chargePayment`) follows the same shape.

### Start the run

trigger.dev dispatches with `tasks.trigger<typeof processOrder>('process-order', { orderId })`. The Workflow SDK calls `start()` directly:

```typescript title="app/api/orders/route.ts"
import { start } from 'workflow/api';
import { processOrder } from '@/workflows/order';

export async function POST(request: Request) {
  const { orderId } = (await request.json()) as { orderId: string };
  const run = await start(processOrder, [orderId]); // [!code highlight]
  return Response.json({ runId: run.runId });
}
```

No id lookup, no API key, no separate worker. `start()` returns a handle immediately.

## Wait for an external signal

`wait.forToken()` becomes `createHook()` plus `await`.

{/* @skip-typecheck: trigger.dev SDK types not available */}

```typescript title="workflow/workflows/refund.ts (trigger.dev, abbreviated)"
// import { wait } from '@trigger.dev/sdk';
const token = await wait.createToken({ timeout: '7d' });
const approval = await wait.forToken<{ approved: boolean }>(token.id).unwrap();
// External system resumes with: await wait.completeToken(token.id, { approved: true });
```

{/* @skip-typecheck: snippet without imports */}

```typescript title="workflow/workflows/refund.ts (Workflow SDK)"
using approval = createHook<{ approved: boolean }>({ // [!code highlight]
  token: `refund:${refundId}:approval`,
});
const payload = await approval;
```

**What changed:** the platform-issued opaque token becomes an app-owned string. The caller that resumes the run supplies that same string, so there is no token lookup. (trigger.dev also exposes `token.url` for external callers; the SDK analog is `createWebhook().url`).

### Resume from an API route

There are two shapes of resume, and `wait.forToken` can map to either:

* **Server-side resume (known token):** `createHook<T>({ token: 'business-token' })` + `resumeHook(token, payload)` from an API route. Use this when your app knows the token shape and controls the resume call.
* **Third-party callback URL (generated token):** `createWebhook({ respondWith: 'default' })` + pass `webhook.url` to the external system. The external system hits the URL to resume.

See [`/docs/foundations/hooks`](/docs/foundations/hooks) for both surfaces.

trigger.dev completes a token with `wait.completeToken(tokenId, { approved })`. The SDK equivalent is `resumeHook`:

```typescript title="app/api/refunds/[refundId]/approve/route.ts"
import { resumeHook } from 'workflow/api';

export async function POST(request: Request, { params }: { params: Promise<{ refundId: string }> }) {
  const { refundId } = await params;
  const { approved } = (await request.json()) as { approved: boolean };
  await resumeHook(`refund:${refundId}:approval`, { approved }); // [!code highlight]
  return Response.json({ ok: true });
}
```

### Add a timeout and branch on the payload

trigger.dev's `timeout: '7d'` option maps to a `Promise.race()` with `sleep()`:

{/* @skip-typecheck: continuation snippet */}

```typescript title="workflow/workflows/refund.ts"
const result = await Promise.race([
  approval.then((p) => ({ type: 'decision' as const, approved: p.approved })),
  sleep('7d').then(() => ({ type: 'timeout' as const })), // [!code highlight]
]);

if (result.type === 'timeout') return { refundId, status: 'timed-out' };
if (!result.approved) return { refundId, status: 'rejected' };
return { refundId, status: 'approved' };
```

<Callout type="info">
  A hook is an inbound write channel. The caller that knows the token resumes the run with a typed payload. For granular business status (progress updates, current-stage messages), prefer writing to `getWritable({ namespace: 'status' })` from a step and reading from the end of the named stream on the client. Use `getRun(runId).status` for terminal/lifecycle state only.
</Callout>

## Spawn a child workflow

`triggerAndWait()` splits into two steps: spawn and collect. `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions. Return the full `Run` object from `spawnChild` so observability tooling can deep-link to the child run.

You can return either the full `Run` object (enables deep-linking) or just `run.runId` (simpler). The runtime serializes `Run` to its `runId` in the event log either way.

```typescript title="workflow/workflows/parent.ts"
import { start } from 'workflow/api';

async function spawnChild(item: string) {
  'use step';
  return start(childWorkflow, [item]); // [!code highlight]
}
```

Await the result in a second step, then orchestrate both from the parent:

```typescript title="workflow/workflows/parent.ts"
import { getRun } from 'workflow/api';

async function collectResult(runId: string) {
  'use step';
  const run = getRun(runId);
  return (await run.returnValue) as { item: string; result: string }; // [!code highlight]
}

export async function parentWorkflow(item: string) {
  'use workflow';
  const child = await spawnChild(item);
  return await collectResult(child.runId);
}
```

To fan out, call `spawnChild` inside a loop, then `Promise.all` the `collectResult` calls. That replaces `batch.triggerAndWait()`.

`Promise.all` rejects on first failure; use `Promise.allSettled` if you need batch-mode error tolerance similar to trigger.dev's `{ ok, output, error }` per-run result.

## What you stop operating

Dropping the trigger.dev SDK removes several moving parts:

* **No task registry or `id` strings.** Workflow files carry directive annotations and export plain functions.
* **No `@trigger.dev/sdk` client or API key.** `start()` launches runs directly from API routes or server actions.
* **No worker fleet or self-hosted instance.** The runtime schedules execution inside the app's deploy target.
* **No separate Realtime channel.** `getWritable()` streams updates from steps over the run's durable stream.
* **No dashboard account.** The built-in observability UI (`npx workflow web`) reads the same event log the runtime writes.

Workflows suspended on `sleep()` or a hook consume no compute until resumed.

## Step-by-step first migration

Pick one trigger.dev task and migrate it end-to-end before touching the rest. The steps below describe the smallest viable path.

### Step 1: Install the Workflow SDK

Add the runtime. Framework integrations (Next.js, Nitro, Nuxt, SvelteKit, Astro, Nest) are subpath exports of the same `workflow` package, e.g. `workflow/next` — no additional install needed.

```bash
pnpm add workflow
```

### Step 2: Convert `task()` to a `"use workflow"` export

Drop the factory call and the `id`. Move the handler body up and replace the payload object with typed function arguments.

```ts title="workflows/order.ts"
// Before (trigger.dev)
// export const processOrder = task({
//   id: "process-order",
//   run: async ({ orderId }: { orderId: string }) => { ... },
// });

// After (Workflow SDK)
export async function processOrder(orderId: string) {
  "use workflow"; // [!code highlight]
  // ...
}
```

### Step 3: Convert the task body into `"use step"` named functions

Each side effect becomes a named function with `"use step"` on the first line. The workflow calls them with a plain `await`. `schemaTask()` validation moves to the call site: validate with zod before calling `start()`.

```ts
async function loadOrder(id: string) {
  "use step"; // [!code highlight]
  return fetch(`/api/orders/${id}`).then((r) => r.json());
}
```

### Step 4: Replace `wait.*` with hooks and `sleep`

* `wait.for({ seconds | minutes | hours | days })` / `wait.until({ date })` → `sleep('5m')` or `sleep(date)` from `workflow`.
* `wait.forToken(token)` → `createHook({ token })` + `await`. Complete it with `resumeHook(token, payload)` from an API route.
* `wait.forToken({ timeout })` → `Promise.race([hook, sleep(timeout)])`.
* `triggerAndWait(payload)` → wrap `start(child, [payload])` in a `"use step"` function and return the `Run` object, then read the result with a second step that calls `getRun(runId).returnValue`.

### Step 5: Start runs from the app

Delete the trigger.dev client setup. Launch runs directly from an API route or server action:

```ts title="app/api/orders/route.ts"
import { start } from "workflow/api";
import { processOrder } from "@/workflows/order";

export async function POST(req: Request) {
  const { orderId } = await req.json();
  const run = await start(processOrder, [orderId]);
  return Response.json({ runId: run.runId });
}
```

### Step 6: Retire trigger.dev infrastructure

Remove the `@trigger.dev/sdk` dependency, the `trigger.config.ts` file, the `trigger/` directory, and any self-hosted worker deployment. Delete dashboard API keys from the environment. Verify the run in `npx workflow web` before shipping.

## Retries on steps

Retry count lives on the step function itself. Set it as a property on the step:

```typescript
async function chargePayment(orderId: string) {
  "use step";
  // ...
}
chargePayment.maxRetries = 5;
```

Throw `new RetryableError(msg, { retryAfter: '5s' })` to control delay between attempts, or `FatalError` to stop retries immediately. See [`/docs/foundations/errors-and-retries`](/docs/foundations/errors-and-retries).

## Features without a 1:1 equivalent

* **`schedules.task()` / cron triggers.** The SDK has no built-in scheduler. Trigger runs from Vercel Cron or a system cron calling `start()`.
* **Concurrency keys / queue concurrency limits.** No direct analog. Enforce limits inside steps (semaphores, external coordinator) or debounce at the publisher.
* **`machine` presets / custom images.** Machine specs are per-task in trigger.dev; in the SDK, function resources are per-deployment (configure via your hosting platform).
* **Realtime / `subscribeToRun`.** Use `getRun(runId).getReadable()` plus named `getWritable()` streams for live progress.
* **`onFailure` lifecycle hook.** No equivalent. Handle cleanup in the workflow body with a try/catch + compensation-stack pattern.
* **Trigger.dev dashboard.** Workflow SDK ships `npx workflow web` for local inspection and the Vercel Observability tab for deployed runs.

## Quick-start checklist

* Replace `task({ id, run })` with a `"use workflow"` function; launch it with `start()`.
* Convert each task body into named `"use step"` functions.
* Swap `wait.for` / `wait.until` for `sleep()` from `workflow`.
* Swap `wait.forToken` for `createHook()` (internal) or `createWebhook()` (HTTP).
* Model `wait.forToken` timeouts as `Promise.race()` between the hook and `sleep()`.
* Replace `triggerAndWait()` with `"use step"` wrappers around `start()` and `getRun()`.
* Replace `batch.triggerAndWait()` with `Promise.all` over the collected child `Run` handles.
* Move `schemaTask` validation to the call site; pass typed arguments into the workflow.
* Replace `AbortTaskRunError` with `FatalError`; model retries per step with `RetryableError` and `maxRetries`.
* Use `getStepMetadata().stepId` as the idempotency key for external side effects.
* Replace `metadata.stream()` and Realtime with `getWritable()`.
* Remove the `@trigger.dev/sdk` dependency, `trigger.config.ts`, and any self-hosted worker.
* Deploy and verify runs end-to-end with the built-in observability UI.

***

*Verified against `workflow@5.0.0-beta.1` and `@trigger.dev/sdk` v3 on 2026-04-16.*


---
title: Observability
description: Inspect, monitor, and debug workflows through the CLI and Web UI with powerful observability tools.
type: overview
summary: Inspect and debug workflow runs using the CLI and Web UI.
prerequisites:
  - /docs/foundations
related:
  - /docs/how-it-works/event-sourcing
  - /docs/how-it-works/encryption
---

# Observability





Workflow SDK provides powerful tools to inspect, monitor, and debug your workflows through the CLI and Web UI. These tools allow you to inspect workflow runs, steps, webhooks, [events](/docs/how-it-works/event-sourcing), and stream output.

## Quick Start

```bash
npx workflow
```

The CLI comes pre-installed with the Workflow SDK and registers the `workflow` command. If the `workflow` package is not already installed, `npx workflow` will download and run the CLI temporarily, or use the local installed version if available.

Get started inspecting your local workflows:

```bash
# See all available commands
npx workflow inspect --help

# List recent workflow runs
npx workflow inspect runs
```

## Web UI

Workflow SDK ships with a local web UI for inspecting your workflows. The CLI
will locally serve the Web UI when using the `--web` flag.

```bash
# Launch Web UI for visual exploration
npx workflow inspect runs --web
```

<img alt="Workflow SDK Web UI" src={__img0} placeholder="blur" />

To share a link to a specific run without opening a browser, use the `--url`
flag. It prints the dashboard deep link to stdout and exits (no browser, no
local server) — useful for scripts, PR comments, or automation. Add `--json` to
get `{ "url": "..." }`.

```bash
# Print the deep-link URL for a run (no browser, no server)
npx workflow inspect run <run_id> --url

# Vercel runs: add the backend (and --env preview for preview deployments)
npx workflow inspect run <run_id> --backend vercel --url
```

## Backends

The Workflow SDK CLI can inspect data from any [World](/docs/deploying). By default, it inspects data in your local development environment. For example, if you are using Next.js to develop workflows locally, the
CLI will find the data in your `.next/workflow-data/` directory.

If you're deploying workflows to a production environment, but want to inspect the data by using the CLI, you can specify the world you are using by setting the `--backend` flag to your world's name or package name, e.g. `vercel`.

<Callout>
  Backends might require additional configuration. If you're missing environment variables, the World package should provide instructions on how to configure it.
</Callout>

### Vercel Backend

To inspect workflows running on Vercel, ensure you're logged in to the Vercel CLI and have linked your project. See [Vercel CLI authentication and project linking docs](https://vercel.com/docs/cli/project-linking) for more information. Then, simply specify the backend as `vercel`.

```bash
# Inspect workflows running on Vercel
npx workflow inspect runs --backend vercel
```

When deployed to Vercel, workflow data is [encrypted end-to-end](/docs/how-it-works/encryption). Encrypted fields display as locked placeholders until you choose to decrypt them using the **Decrypt** button in the web UI or the `--decrypt` flag in the CLI.


---
title: Testing
description: Unit test individual steps and integration test entire workflows using Vitest.
---

# Testing





Testing is a critical part of building reliable workflows. Because steps are just functions annotated with directives, they can be unit tested like any other JavaScript function. Workflow SDK also provides a Vitest plugin that runs full workflows in-process — no running server required.

This guide covers two approaches:

1. **Unit testing** - Test individual steps as plain functions, without the workflow runtime.
2. **Integration testing** - Test entire workflows in-process using the `workflow()` Vitest plugin. Required when you want to test workflow specific code paths, like those using [hooks](/docs/foundations/hooks), webhooks, [`sleep()`](/docs/api-reference/workflow/sleep), retries, etc.

## Unit Testing Steps

Without the workflow compiler, the `"use step"` directive is a no-op. Your step functions run as regular JavaScript functions, making them straightforward to unit test with no special configuration.

### Example Steps

Given a workflow file with step functions like this:

```typescript title="workflows/user-signup.ts" lineNumbers
import { sleep } from "workflow";

export async function handleUserSignup(email: string) {
  "use workflow";

  const user = await createUser(email);
  await sendWelcomeEmail(user);

  await sleep("5d");
  await sendOnboardingEmail(user);

  return { userId: user.id, status: "onboarded" };
}

export async function createUser(email: string) {
  "use step"; // [!code highlight]
  return { id: crypto.randomUUID(), email };
}

export async function sendWelcomeEmail(user: { id: string; email: string }) {
  "use step"; // [!code highlight]
  // Send email logic
}

export async function sendOnboardingEmail(user: { id: string; email: string }) {
  "use step"; // [!code highlight]
  // Send email logic
}
```

### Writing Unit Tests for Steps

You can import and test step functions directly with Vitest. No special configuration or workflow plugin is needed:

```typescript title="workflows/user-signup.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { createUser, sendWelcomeEmail } from "./user-signup"; // [!code highlight]

describe("createUser step", () => {
  it("should create a user with the given email", async () => {
    const user = await createUser("test@example.com");

    expect(user.email).toBe("test@example.com");
    expect(user.id).toBeDefined();
  });
});

describe("sendWelcomeEmail step", () => {
  it("should send a welcome email without throwing", async () => {
    const user = { id: "user-1", email: "test@example.com" };
    await expect(sendWelcomeEmail(user)).resolves.not.toThrow();
  });
});
```

This approach is ideal for verifying the business logic inside individual steps in isolation.

<Callout type="info">
  Unit testing works well for individual steps. A simple workflow that only calls steps can also be unit tested this way, since `"use workflow"` is similarly a no-op without the compiler. However, any workflow that uses runtime features like [`sleep()`](/docs/api-reference/workflow/sleep), [hooks](/docs/foundations/hooks), or [webhooks](/docs/foundations/hooks#understanding-webhooks) cannot be unit tested directly because those APIs require the workflow runtime. Use [integration testing](#integration-testing-with-the-vitest-plugin) for testing entire workflows, especially those that depend on workflow-only features.
</Callout>

## Integration Testing with the Vitest Plugin

For workflows that rely on runtime features like [hooks](/docs/foundations/hooks), [webhooks](/docs/foundations/hooks#understanding-webhooks), [`sleep()`](/docs/api-reference/workflow/sleep), or error retries, you need to test against a real workflow setup. The `@workflow/vitest` plugin handles everything automatically — it compiles your workflow directives, builds the runtime bundles, and executes workflows entirely in-process. No server required.

<Callout type="warn">
  `vi.mock()` and related calls do *not* work inside workflow functions, only step functions. Your workflow functions cannot import third party code that needs to be mocked. Mocking works for npm packages imported in step functions. If something needs to be mocked, it likely belongs inside a step function either way.
</Callout>

### Vitest Configuration

Create a separate Vitest config for integration tests that includes the `workflow()` plugin:

```typescript title="vitest.integration.config.ts" lineNumbers
import { defineConfig } from "vitest/config";
import { workflow } from "@workflow/vitest"; // [!code highlight]

export default defineConfig({
  plugins: [workflow()], // [!code highlight]
  test: {
    include: ["**/*.integration.test.ts"],
    testTimeout: 60_000, // Workflows may take longer than default timeout
  },
});
```

That's it. The plugin automatically:

1. Transforms `"use workflow"` and `"use step"` directives via SWC
2. Builds workflow and step bundles before tests run
3. Sets up an in-process workflow runtime using a fresh [Local World](/worlds/local) instance in each test worker — all workflow data is cleared automatically between test files for full isolation

<Callout type="info">
  Use a separate Vitest configuration and a distinct file naming convention (e.g. `*.integration.test.ts`) to keep unit tests and integration tests separate. Unit tests run with a standard Vitest config without the workflow plugin, while integration tests use the config above.
</Callout>

### Writing Integration Tests

Use [`start()`](/docs/api-reference/workflow-api/start) to trigger a workflow and [`run.returnValue`](/docs/api-reference/workflow-api/start#returns) to get the result. `returnValue` is a promise that blocks until the workflow completes (or throws if it fails):

```typescript title="workflows/calculate.integration.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { start } from "workflow/api"; // [!code highlight]
import { calculateWorkflow } from "./calculate";

describe("calculateWorkflow", () => {
  it("should compute the correct result", async () => {
    const run = await start(calculateWorkflow, [2, 7]); // [!code highlight]

    expect(run.runId).toMatch(/^wrun_/);

    // Blocks until the workflow completes or fails
    const result = await run.returnValue; // [!code highlight]
    expect(result).toEqual({
      sum: 9,
      product: 14,
      combined: 23,
    });

    const status = await run.status; // [!code highlight]
    expect(status).toEqual("completed");
  });
});
```

### Testing Hooks and Waits

The real power of integration testing comes when testing workflow-only features. Hooks and waits can be resumed programmatically using the [`workflow/api`](/docs/api-reference/workflow-api) functions, making it straightforward to simulate external events in your tests.

Given a workflow that waits for approval via a hook, then sleeps before publishing:

```typescript title="workflows/approval.ts" lineNumbers
import { createHook, sleep } from "workflow";

export async function approvalWorkflow(documentId: string) {
  "use workflow";

  const prepared = await prepareDocument(documentId);

  using hook = createHook<{ approved: boolean; reviewer: string }>({ // [!code highlight]
    token: `approval:${documentId}`, // [!code highlight]
  }); // [!code highlight]

  const decision = await hook; // [!code highlight]

  if (decision.approved) {
    // Wait 24 hours before publishing (e.g. grace period for retractions)
    await sleep("24h"); // [!code highlight]
    await publishDocument(prepared);
    return { status: "published", reviewer: decision.reviewer };
  }

  return { status: "rejected", reviewer: decision.reviewer };
}

async function prepareDocument(documentId: string) {
  "use step";
  return { id: documentId, content: "..." };
}

async function publishDocument(doc: { id: string; content: string }) {
  "use step";
  console.log(`Publishing document ${doc.id}`);
}
```

You can write an integration test that starts the workflow, waits for the hook and sleep to be reached, then resumes them programmatically:

```typescript title="workflows/approval.integration.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { start, getRun, resumeHook } from "workflow/api"; // [!code highlight]
import { waitForHook, waitForSleep } from "@workflow/vitest"; // [!code highlight]
import { approvalWorkflow } from "./approval";

describe("approvalWorkflow", () => {
  it("should publish when approved", async () => {
    const run = await start(approvalWorkflow, ["doc-123"]); // [!code highlight]

    // Wait for the hook to be created, then resume it
    await waitForHook(run, { token: "approval:doc-123" }); // [!code highlight]
    await resumeHook("approval:doc-123", { // [!code highlight]
      approved: true, // [!code highlight]
      reviewer: "alice", // [!code highlight]
    }); // [!code highlight]

    // Wait for the first pending sleep to be reached.
    // waitForSleep() returns the sleep's correlation ID, which can be
    // passed to wakeUp() later to target a specific sleep in the workflow.
    const sleepId = await waitForSleep(run); // [!code highlight]

    // Calling wakeUp() without correlationIds would resume all active sleeps
    await getRun(run.runId).wakeUp({ correlationIds: [sleepId] }); // [!code highlight]

    const result = await run.returnValue;
    expect(result).toEqual({
      status: "published",
      reviewer: "alice",
    });
  });

  it("should reject when not approved", async () => {
    const run = await start(approvalWorkflow, ["doc-456"]);

    await waitForHook(run, { token: "approval:doc-456" });
    await resumeHook("approval:doc-456", {
      approved: false,
      reviewer: "bob",
    });

    // No wakeUp() needed here — the rejected path has no sleep
    const result = await run.returnValue;
    expect(result).toEqual({
      status: "rejected",
      reviewer: "bob",
    });
  });
});
```

<Callout type="info">
  [`start()`](/docs/api-reference/workflow-api/start), [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook), and [`getRun().wakeUp()`](/docs/api-reference/workflow-api/get-run) are the key API functions for integration testing. Use `start()` to trigger a workflow, `resumeHook()` to simulate external events, and `wakeUp()` to skip `sleep()` calls so tests run instantly. [`waitForSleep()`](/docs/api-reference/vitest#waitforsleep) and [`waitForHook()`](/docs/api-reference/vitest#waitforhook) from `@workflow/vitest` let you wait for the workflow to reach a specific point before resuming. See the [API Reference](/docs/api-reference/workflow-api) for the full list of available functions.
</Callout>

<Callout type="info">
  `waitForSleep()` returns the first **pending** sleep — one that has a `wait_created` event but no corresponding `wait_completed` event. If your workflow has multiple parallel sleeps, `waitForSleep()` returns whichever is found first. After waking one, call `waitForSleep()` again to get the next pending one. For sequential sleeps, `waitForSleep()` naturally returns each one as the workflow reaches it.
</Callout>

### Testing Webhooks

Webhooks are hooks that receive HTTP `Request` objects. In tests, resume them using [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) with a `Request` payload — no HTTP server needed:

```typescript title="workflows/ingest.ts" lineNumbers
import { createWebhook } from "workflow";

export async function ingestWorkflow(endpointId: string) {
  "use workflow";

  // Webhook tokens are always randomly generated
  using webhook = createWebhook(); // [!code highlight]

  const request = await webhook; // [!code highlight]
  const body = await request.text();
  const data = await parsePayload(body);

  return { endpointId, received: data };
}

async function parsePayload(body: string) {
  "use step";
  return JSON.parse(body);
}
```

```typescript title="workflows/ingest.integration.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { start, resumeWebhook } from "workflow/api"; // [!code highlight]
import { waitForHook } from "@workflow/vitest"; // [!code highlight]
import { ingestWorkflow } from "./ingest";

describe("ingestWorkflow", () => {
  it("should process webhook data", async () => {
    const run = await start(ingestWorkflow, ["ep-1"]);

    // Discover the randomly generated webhook token
    const hook = await waitForHook(run); // [!code highlight]

    // Resume the webhook with a Request object
    await resumeWebhook( // [!code highlight]
      hook.token, // [!code highlight]
      new Request("https://example.com/webhook", { // [!code highlight]
        method: "POST", // [!code highlight]
        body: JSON.stringify({ event: "order.created", orderId: "123" }), // [!code highlight]
      }) // [!code highlight]
    ); // [!code highlight]

    const result = await run.returnValue;
    expect(result).toEqual({
      endpointId: "ep-1",
      received: { event: "order.created", orderId: "123" },
    });
  });
});
```

### Manual Setup

If you need more control over the test lifecycle, the plugin also exports the individual setup functions:

```typescript title="vitest.integration.config.ts" lineNumbers
import { defineConfig } from "vitest/config";
import { workflowTransformPlugin } from "@workflow/rollup";

export default defineConfig({
  plugins: [workflowTransformPlugin()],
  test: {
    include: ["**/*.integration.test.ts"],
    testTimeout: 60_000,
    globalSetup: "./vitest.integration.setup.ts",
    setupFiles: ["./vitest.integration.env.ts"],
  },
});
```

```typescript title="vitest.integration.setup.ts"
import { buildWorkflowTests } from "@workflow/vitest";

export async function setup() {
  await buildWorkflowTests();
}
```

```typescript title="vitest.integration.env.ts"
import { beforeAll, afterAll } from "vitest";
import {
  setupWorkflowTests,
  teardownWorkflowTests,
} from "@workflow/vitest";

beforeAll(async () => {
  await setupWorkflowTests();
});

afterAll(async () => {
  await teardownWorkflowTests();
});
```

<Callout type="info">
  `setupWorkflowTests()` automatically clears all workflow data (runs, events, hooks) on each invocation, ensuring full test isolation between test files.
</Callout>

<Callout type="info">
  For advanced setups that require a running server (e.g. testing against your actual framework's HTTP layer), see [Server-based integration testing](/docs/testing/server-based).
</Callout>

## Debugging Test Runs

When integration tests fail, the [Workflow SDK CLI and Web UI](/docs/observability) can help you inspect what happened. Because integration tests persist workflow state locally, you can use the same observability tools you would use in development.

Launch the Web UI to visually explore your test workflow runs:

```bash
npx workflow web
```

Or use the CLI to inspect runs in the terminal:

```bash
# List recent workflow runs
npx workflow inspect runs

# Inspect a specific run
npx workflow inspect run <run-id>
```

The Web UI shows each step, its inputs and outputs, retry attempts, hook state, and timing. This is especially useful for diagnosing issues with hooks that were not resumed, steps that failed unexpectedly, or workflows that timed out.

<img alt="Workflow SDK Web UI" src={__img0} placeholder="blur" />

<Callout type="info">
  See the [Observability](/docs/observability) docs for the full set of CLI commands and Web UI features.
</Callout>

## Best Practices

### Separate Unit and Integration Tests

Keep two test configurations:

* **Unit tests** - Standard Vitest config, no workflow plugin. Fast, no infrastructure required.
* **Integration tests** - Vitest config with `workflow()` plugin. Tests the full workflow lifecycle including hooks, sleeps, and retries.

### Use Custom Hook Tokens for Deterministic Testing

When testing workflows with hooks, use [custom tokens](/docs/foundations/hooks#custom-tokens-for-deterministic-hooks) based on predictable values (like document IDs or test identifiers). This makes it easy to resume the correct hook in your test code.

### Set Appropriate Timeouts

Workflows may take longer to execute than typical unit tests, especially when they involve multiple steps or retries. Set a generous `testTimeout` in your integration test config.

### Test Error and Retry Scenarios

Integration tests are the right place to verify that your workflows handle errors correctly, including retryable errors, fatal errors, and timeout scenarios.

## Further Reading

* [Hooks & Webhooks](/docs/foundations/hooks) - Pausing and resuming workflows with external data
* [`start()` API Reference](/docs/api-reference/workflow-api/start) - Start workflows programmatically
* [`resumeHook()` API Reference](/docs/api-reference/workflow-api/resume-hook) - Resume hooks with data
* [`resumeWebhook()` API Reference](/docs/api-reference/workflow-api/resume-webhook) - Resume webhooks with Request objects
* [`getRun()` API Reference](/docs/api-reference/workflow-api/get-run) - Check workflow run status and wake up sleeping runs
* [`@workflow/vitest` API Reference](/docs/api-reference/vitest) - Test helpers: `waitForSleep()`, `waitForHook()`, and plugin setup
* [Vite Integration](/docs/getting-started/vite) - Set up the Vite plugin
* [Observability](/docs/observability) - Inspect and debug workflow runs with the CLI and Web UI
* [Server-based testing](/docs/testing/server-based) - Integration testing with a running server

***

<Callout type="info">
  This guide was inspired by the testing approach described in Mux's article [*Launching durable AI workflows for video with @mux/ai*](https://www.mux.com/blog/launching-durable-ai-workflows-for-video-with-mux-ai#testing), which demonstrates how Mux uses the `workflow/vite` plugin with Vitest to integration test their durable AI video workflows built on Workflow SDK.
</Callout>


---
title: Server-Based Testing
description: Integration test workflows against a running server when you need to test the full HTTP layer.
---

# Server-Based Testing



The [Vitest plugin](/docs/testing#integration-testing-with-the-vitest-plugin) runs workflows entirely in-process and is the recommended approach for most testing scenarios. However, there are cases where you may want to test against a running server:

* Testing the full HTTP layer (middleware, authentication, request handling)
* Reproducing behavior that only occurs in a specific framework's runtime (e.g. Next.js, Nitro)
* Testing webhook endpoints that receive real HTTP requests

This guide shows how to set up integration tests that spawn a dev server as a sidecar process. The example below uses [Nitro](https://v3.nitro.build), but the same pattern works with any supported server framework. It is meant as a starting point — customize the server setup to match your own deployment environment.

## Vitest Configuration

Create a Vitest config with the `workflow()` Vite plugin for code transforms and a `globalSetup` script that manages the server lifecycle:

```typescript title="vitest.server.config.ts" lineNumbers
import { defineConfig } from "vitest/config";
import { workflow } from "workflow/vite"; // [!code highlight]

export default defineConfig({
  plugins: [workflow()], // [!code highlight]
  test: {
    include: ["**/*.server.test.ts"],
    testTimeout: 60_000,
    globalSetup: "./vitest.server.setup.ts", // [!code highlight]
    env: {
      WORKFLOW_LOCAL_BASE_URL: "http://localhost:4000", // [!code highlight]
    },
  },
});
```

<Callout type="info">
  Note the import path: `workflow/vite` (not `@workflow/vitest`). The Vite plugin handles code transforms but does not set up in-process execution. The server handles workflow execution instead.
</Callout>

## Global Setup Script

The `globalSetup` script starts a dev server before tests run and tears it down afterwards. This example uses [Nitro](https://v3.nitro.build), but you can use any server framework that supports the workflow runtime.

```typescript title="vitest.server.setup.ts" lineNumbers
import { spawn } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
import type { ChildProcess } from "node:child_process";

let server: ChildProcess | null = null;
const PORT = "4000";

function emitSetupLog(event: string, fields: Record<string, unknown> = {}) {
  console.log(
    JSON.stringify({
      scope: "workflow-server-test",
      event,
      port: PORT,
      ...fields,
    })
  );
}

export async function setup() { // [!code highlight]
  const stdout: string[] = [];
  const stderr: string[] = [];

  emitSetupLog("server_starting", {
    command: `npx nitro dev --port ${PORT}`,
  });

  server = spawn("npx", ["nitro", "dev", "--port", PORT], {
    stdio: "pipe",
    detached: false,
    env: process.env,
  });

  const ready = await new Promise<boolean>((resolve) => {
    const timeout = setTimeout(() => resolve(false), 15_000);

    server?.stdout?.on("data", (data) => {
      const output = data.toString();
      stdout.push(output);
      emitSetupLog("server_stdout", { message: output.trim() });

      if (output.includes("listening") || output.includes("ready")) {
        clearTimeout(timeout);
        resolve(true);
      }
    });

    server?.stderr?.on("data", (data) => {
      const output = data.toString();
      stderr.push(output);
      emitSetupLog("server_stderr", { message: output.trim() });
    });

    server?.on("error", (error) => {
      emitSetupLog("server_process_error", {
        name: error.name,
        message: error.message,
      });
      clearTimeout(timeout);
      resolve(false);
    });

    server?.on("exit", (code, signal) => {
      emitSetupLog("server_exit", { code, signal });
    });
  });

  if (!ready) {
    const recentStdout = stdout.join("").trim().slice(-2000);
    const recentStderr = stderr.join("").trim().slice(-2000);

    throw new Error(
      [
        "Server failed to start within 15 seconds.",
        `Command: npx nitro dev --port ${PORT}`,
        `WORKFLOW_LOCAL_BASE_URL: http://localhost:${PORT}`,
        `Recent stdout:\n${recentStdout || "(empty)"}`,
        `Recent stderr:\n${recentStderr || "(empty)"}`,
      ].join("\n\n")
    );
  }

  await delay(2_000);

  process.env.WORKFLOW_LOCAL_BASE_URL = `http://localhost:${PORT}`; // [!code highlight]

  emitSetupLog("server_ready", {
    baseUrl: process.env.WORKFLOW_LOCAL_BASE_URL,
  });
}

export async function teardown() { // [!code highlight]
  if (!server) return;

  emitSetupLog("server_stopping");

  server.kill("SIGTERM");
  await delay(1_000);

  if (!server.killed) {
    emitSetupLog("server_force_kill");
    server.kill("SIGKILL");
  }
}
```

These JSON log lines are intentional. They give CI jobs, local tooling, and agents stable events to watch for (`server_starting`, `server_stdout`, `server_stderr`, `server_ready`, `server_exit`), and the thrown timeout error includes the command, expected `WORKFLOW_LOCAL_BASE_URL`, and buffered stdout/stderr so a failed setup is actionable without interactive debugging.

The setup script sets `WORKFLOW_LOCAL_BASE_URL` so the workflow runtime sends step execution requests to the running server.

<Callout type="info">
  You can use any server framework that supports the workflow runtime. The example above uses [Nitro](https://v3.nitro.build), but you could also use [Next.js](https://nextjs.org), [Hono](https://hono.dev), or any other supported server.
</Callout>

## Writing Tests

Tests are written the same way as [in-process integration tests](/docs/testing#writing-integration-tests). You can use the same programmatic APIs — [`start()`](/docs/api-reference/workflow-api/start), [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook), [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook), and [`getRun().wakeUp()`](/docs/api-reference/workflow-api/get-run) — to control workflow execution:

```typescript title="workflows/calculate.server.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { start, getRun, resumeHook } from "workflow/api";
import { calculateWorkflow } from "./calculate";
import { approvalWorkflow } from "./approval";

describe("calculateWorkflow", () => {
  it("should compute the correct result", async () => {
    const run = await start(calculateWorkflow, [2, 7]);
    const result = await run.returnValue;

    expect(result).toEqual({
      sum: 9,
      product: 14,
      combined: 23,
    });
  });
});

describe("approvalWorkflow", () => {
  it("should publish when approved", async () => {
    const run = await start(approvalWorkflow, ["doc-1"]);

    // Use resumeHook and wakeUp to control workflow execution
    await resumeHook("approval:doc-1", {
      approved: true,
      reviewer: "alice",
    });

    await getRun(run.runId).wakeUp();

    const result = await run.returnValue;
    expect(result).toEqual({
      status: "published",
      reviewer: "alice",
    });
  });
});
```

<Callout type="info">
  In server-based tests, the `waitForSleep()` and `waitForHook()` helpers from `@workflow/vitest` are not available since there is no in-process world. Instead, use the programmatic APIs directly — you may need to add short delays or polling to ensure the workflow has reached the desired state before resuming.
</Callout>

## Running Tests

Add a script to your `package.json`:

```json title="package.json"
{
  "scripts": {
    "test": "vitest",
    "test:server": "vitest --config vitest.server.config.ts"
  }
}
```

## When to Use This Approach

| Scenario                                      | Recommended approach               |
| --------------------------------------------- | ---------------------------------- |
| Testing workflow logic, steps, hooks, retries | [In-process plugin](/docs/testing) |
| Testing HTTP middleware or authentication     | Server-based                       |
| Testing webhook endpoints with real HTTP      | Server-based                       |
| CI/CD pipeline testing                        | [In-process plugin](/docs/testing) |
| Reproducing framework-specific behavior       | Server-based                       |


---
title: @workflow/vitest
description: Vitest plugin and test helpers for integration testing workflows in-process.
---

# @workflow/vitest



The `@workflow/vitest` package provides a Vitest plugin and test helpers for running full workflow integration tests in-process — no server required.

## Plugin

### `workflow()`

Returns a Vite plugin array that handles SWC transforms, bundle building, and in-process handler registration automatically.

```typescript
import { defineConfig } from "vitest/config";
import { workflow } from "@workflow/vitest"; // [!code highlight]

export default defineConfig({
  plugins: [workflow()], // [!code highlight]
});
```

Pass a [`WorkflowTestOptions`](#workflowtestoptions) object when your project uses a non-standard layout — for example, a monorepo where `workflows/` does not live at the Vitest config's directory, or when the default `.workflow-data` / `.workflow-vitest` output locations need to move. The plugin forwards these paths to `buildWorkflowTests()` and `setupWorkflowTests()` through Vitest's per-project provided context, so each Vitest workspace project stays isolated.

```typescript
import { defineConfig } from "vitest/config";
import { workflow } from "@workflow/vitest";

export default defineConfig({
  plugins: [
    workflow({
      cwd: "./apps/api",
      rootDir: "./apps/api/test-artifacts",
    }),
  ],
});
```

**Parameters:**

| Parameter  | Type                  | Description            |
| ---------- | --------------------- | ---------------------- |
| `options?` | `WorkflowTestOptions` | Optional configuration |

**Returns:** `Plugin[]`

## Setup Functions

### `buildWorkflowTests()`

Builds workflow and step bundles to disk. Called automatically by the `workflow()` plugin in `globalSetup`. Use directly only for [manual setup](/docs/testing#manual-setup).

```typescript
import { buildWorkflowTests } from "@workflow/vitest";

export async function setup() {
  await buildWorkflowTests();
}
```

**Parameters:**

| Parameter  | Type                  | Description            |
| ---------- | --------------------- | ---------------------- |
| `options?` | `WorkflowTestOptions` | Optional configuration |

### `setupWorkflowTests()`

Sets up an in-process workflow runtime in each test worker. Imports pre-built bundles, creates a [Local World](/worlds/local) instance with direct handlers, and sets it as the global world. Clears all workflow data on each invocation for full test isolation.

Called automatically by the `workflow()` plugin in `setupFiles`. Use directly only for [manual setup](/docs/testing#manual-setup).

```typescript
import { beforeAll, afterAll } from "vitest";
import { setupWorkflowTests, teardownWorkflowTests } from "@workflow/vitest";

beforeAll(async () => {
  await setupWorkflowTests();
});

afterAll(async () => {
  await teardownWorkflowTests();
});
```

**Parameters:**

| Parameter  | Type                  | Description            |
| ---------- | --------------------- | ---------------------- |
| `options?` | `WorkflowTestOptions` | Optional configuration |

### `teardownWorkflowTests()`

Tears down the workflow test world. Clears the global world and closes the Local World instance. Called automatically by the `workflow()` plugin.

**Returns:** `Promise<void>`

### `WorkflowTestOptions`

| Option    | Type     | Default                      | Description                                                                                                                                                                                    |
| --------- | -------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cwd`     | `string` | `process.cwd()`              | The working directory of the project (where `workflows/` lives). Relative paths resolve against `process.cwd()`.                                                                               |
| `rootDir` | `string` | same as `cwd`                | Root directory used for default test artifacts. When set, `dataDir` and `outDir` default to `<rootDir>/.workflow-data` and `<rootDir>/.workflow-vitest`. Relative paths resolve against `cwd`. |
| `dataDir` | `string` | `<rootDir>/.workflow-data`   | Directory for workflow runtime data written by the test world. Relative paths resolve against `cwd`.                                                                                           |
| `outDir`  | `string` | `<rootDir>/.workflow-vitest` | Directory for generated workflow and step bundles. Relative paths resolve against `cwd`.                                                                                                       |

## Test Helpers

### `waitForSleep()`

Polls the event log until the workflow has a pending `sleep()` call — one with a `wait_created` event but no corresponding `wait_completed` event. Returns the correlation ID of the pending sleep, which can be passed to [`wakeUp()`](/docs/api-reference/workflow-api/get-run) to target a specific sleep.

```typescript
import { waitForSleep } from "@workflow/vitest"; // [!code highlight]
import { start, getRun } from "workflow/api";

const run = await start(myWorkflow, []);
const sleepId = await waitForSleep(run); // [!code highlight]
await getRun(run.runId).wakeUp({ correlationIds: [sleepId] }); // [!code highlight]
```

**Parameters:**

| Parameter  | Type          | Description                       |
| ---------- | ------------- | --------------------------------- |
| `run`      | `Run<any>`    | The workflow run to monitor       |
| `options?` | `WaitOptions` | Polling and timeout configuration |

**Returns:** `Promise<string>` — The correlation ID of the first pending sleep. Pass this to `wakeUp({ correlationIds: [id] })` to target a specific sleep.

#### Behavior with Multiple Sleeps

* **Sequential sleeps**: `waitForSleep()` returns each sleep as the workflow reaches it. After waking one, call `waitForSleep()` again for the next.
* **Parallel sleeps**: `waitForSleep()` returns whichever pending sleep is found first. After waking it, call `waitForSleep()` again to get the next one.

### `waitForHook()`

Polls the hook list and event log until a hook matching the optional `token` filter exists that hasn't been received yet. Returns the matching hook object.

```typescript
import { waitForHook } from "@workflow/vitest"; // [!code highlight]
import { start, resumeHook } from "workflow/api";

const run = await start(myWorkflow, ["doc-1"]);
const hook = await waitForHook(run, { token: "approval:doc-1" }); // [!code highlight]
await resumeHook(hook.token, { approved: true }); // [!code highlight]
```

**Parameters:**

| Parameter  | Type                               | Description                                 |
| ---------- | ---------------------------------- | ------------------------------------------- |
| `run`      | `Run<any>`                         | The workflow run to monitor                 |
| `options?` | `WaitOptions & { token?: string }` | Polling, timeout, and optional token filter |

**Returns:** `Promise<Hook>` — The first pending hook matching the filter. The hook object includes `token`, `hookId`, and `runId`.

### `WaitOptions`

Both `waitForSleep()` and `waitForHook()` accept options for controlling polling behavior:

| Option         | Type     | Default | Description                          |
| -------------- | -------- | ------- | ------------------------------------ |
| `timeout`      | `number` | `30000` | Maximum time to wait in milliseconds |
| `pollInterval` | `number` | `100`   | Polling interval in milliseconds     |


---
title: DurableAgent
description: Deprecated DurableAgent API reference; use WorkflowAgent for new durable agents.
type: reference
summary: Deprecated: use AI SDK's WorkflowAgent instead of DurableAgent.
prerequisites:
  - /docs/ai
related:
  - /docs/ai/defining-tools
---

# DurableAgent



<Callout type="warn">
  `DurableAgent` is deprecated. Use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) for new durable agents — see the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent).
</Callout>

This reference is kept for existing applications that still import `DurableAgent` from `@workflow/ai/agent`. Do not use `DurableAgent` for new code.

For current examples and implementation guidance, see AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) docs. For legacy code, the API surface below documents the existing `DurableAgent` exports.

## API Signature

### Class

<TSDoc
  definition={`
import { DurableAgent } from "@workflow/ai/agent";
export default DurableAgent;`}
/>

### DurableAgentOptions

<TSDoc
  definition={`
import type { DurableAgentOptions } from "@workflow/ai/agent";
export default DurableAgentOptions;`}
/>

### DurableAgentStreamOptions

<TSDoc
  definition={`
import type { DurableAgentStreamOptions } from "@workflow/ai/agent";
export default DurableAgentStreamOptions;`}
/>

### DurableAgentStreamResult

The result returned from the `stream()` method:

<TSDoc
  definition={`
import type { DurableAgentStreamResult } from "@workflow/ai/agent";
export default DurableAgentStreamResult;`}
/>

### GenerationSettings

Settings that control model generation behavior. These can be set on the constructor or overridden per-stream call:

<TSDoc
  definition={`
import type { GenerationSettings } from "@workflow/ai/agent";
export default GenerationSettings;`}
/>

### PrepareStepInfo

Information passed to the `prepareStep` callback:

<TSDoc
  definition={`
import type { PrepareStepInfo } from "@workflow/ai/agent";
export default PrepareStepInfo;`}
/>

### PrepareStepResult

Return type from the `prepareStep` callback:

<TSDoc
  definition={`
import type { PrepareStepResult } from "@workflow/ai/agent";
export default PrepareStepResult;`}
/>

### TelemetrySettings

Configuration for observability and telemetry:

<TSDoc
  definition={`
import type { TelemetrySettings } from "@workflow/ai/agent";
export default TelemetrySettings;`}
/>

### Callbacks

#### StreamTextOnFinishCallback

Called when streaming completes:

<TSDoc
  definition={`
import type { StreamTextOnFinishCallback } from "@workflow/ai/agent";
export default StreamTextOnFinishCallback;`}
/>

#### StreamTextOnErrorCallback

Called when an error occurs:

<TSDoc
  definition={`
import type { StreamTextOnErrorCallback } from "@workflow/ai/agent";
export default StreamTextOnErrorCallback;`}
/>

#### StreamTextOnAbortCallback

Called when the operation is aborted:

<TSDoc
  definition={`
import type { StreamTextOnAbortCallback } from "@workflow/ai/agent";
export default StreamTextOnAbortCallback;`}
/>

### Advanced Types

#### ToolCallRepairFunction

Function to repair malformed tool calls:

<TSDoc
  definition={`
import type { ToolCallRepairFunction } from "@workflow/ai/agent";
export default ToolCallRepairFunction;`}
/>

#### StreamTextTransform

Transform applied to the stream:

<TSDoc
  definition={`
import type { StreamTextTransform } from "@workflow/ai/agent";
export default StreamTextTransform;`}
/>

#### OutputSpecification

Specification for structured output parsing:

<TSDoc
  definition={`
import type { OutputSpecification } from "@workflow/ai/agent";
export default OutputSpecification;`}
/>

## Key Features

* **Durable Execution**: Agents can be interrupted and resumed without losing state
* **Flexible Tool Implementation**: Tools can be implemented as workflow steps for automatic retries, or as regular workflow-level logic
* **Stream Processing**: Handles streaming responses and tool calls in a structured way
* **Workflow Native**: Fully integrated with Workflow SDK for production-grade reliability
* **AI SDK Parity**: Supports the same options as AI SDK's `streamText` including generation settings, callbacks, and structured output

## Good to Know

* Tools can be implemented as workflow steps (using `"use step"` for automatic retries), or as regular workflow-level logic
* Tools can use core library features like `sleep()` and Hooks within their `execute` functions
* The agent processes tool calls iteratively until completion or `maxSteps` is reached
* **Default `maxSteps` is unlimited** - set a value to limit the number of LLM calls
* The `stream()` method returns `{ messages, steps, toolCalls, toolResults, experimental_output, uiMessages }` containing the full conversation history, step details, tool call details, optional structured output, and optionally accumulated UI messages
* Use `collectUIMessages: true` to accumulate `UIMessage[]` during streaming, useful for persisting conversation state without re-reading the stream
* The `prepareStep` callback runs before each step and can modify model, messages, generation settings, tool choice, and context
* Generation settings (temperature, maxOutputTokens, etc.) can be set on the constructor and overridden per-stream call
* Use `activeTools` to limit which tools are available for a specific stream call
* The `onFinish` callback is called when all steps complete; `onAbort` is called if aborted

## Examples

### Basic Agent with Tools

```typescript
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import { z } from "zod";
import type { UIMessageChunk } from "ai";

async function getWeather({ location }: { location: string }) {
  "use step";
  // Fetch weather data
  const response = await fetch(`https://api.weather.com?location=${location}`);
  return response.json();
}

async function weatherAgentWorkflow(userQuery: string) {
  "use workflow";

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    tools: {
      getWeather: {
        description: "Get current weather for a location",
        inputSchema: z.object({ location: z.string() }),
        execute: getWeather,
      },
    },
    instructions: "You are a helpful weather assistant. Always provide accurate weather information.",
  });

  await agent.stream({
    messages: [
      {
        role: "user",
        content: userQuery,
      },
    ],
    writable: getWritable<UIMessageChunk>(),
  });
}
```

### Multiple Tools

```typescript
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import { z } from "zod";
import type { UIMessageChunk } from "ai";

async function getWeather({ location }: { location: string }) {
  "use step";
  return `Weather in ${location}: Sunny, 72°F`;
}

async function searchEvents({ location, category }: { location: string; category: string }) {
  "use step";
  return `Found 5 ${category} events in ${location}`;
}

async function multiToolAgentWorkflow(userQuery: string) {
  "use workflow";

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    tools: {
      getWeather: {
        description: "Get weather for a location",
        inputSchema: z.object({ location: z.string() }),
        execute: getWeather,
      },
      searchEvents: {
        description: "Search for upcoming events in a location",
        inputSchema: z.object({ location: z.string(), category: z.string() }),
        execute: searchEvents,
      },
    },
  });

  await agent.stream({
    messages: [
      {
        role: "user",
        content: userQuery,
      },
    ],
    writable: getWritable<UIMessageChunk>(),
  });
}
```

### Multi-turn Conversation

```typescript
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import type { UIMessageChunk } from "ai";
import { z } from "zod";

async function searchProducts({ query }: { query: string }) {
  "use step";
  // Search product database
  return `Found 3 products matching "${query}"`;
}

async function multiTurnAgentWorkflow() {
  "use workflow";

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    tools: {
      searchProducts: {
        description: "Search for products",
        inputSchema: z.object({ query: z.string() }),
        execute: searchProducts,
      },
    },
  });

  const writable = getWritable<UIMessageChunk>();

  // First user message
  //   - Result is streamed to the provided `writable` stream
  //   - Message history is returned in `messages` for LLM context
  let { messages } = await agent.stream({
    messages: [
      { role: "user", content: "Find me some laptops" }
    ],
    writable,
  });

  // Continue the conversation with the accumulated message history
  const result = await agent.stream({
    messages: [
      ...messages,
      { role: "user", content: "Which one has the best battery life?" }
    ],
    writable,
  });

  // result.messages now contains the complete conversation history
  return result.messages;
}
```

### Tools with Workflow Library Features

```typescript
import { DurableAgent } from "@workflow/ai/agent";
import { sleep, defineHook, getWritable } from "workflow";
import { z } from "zod";
import type { UIMessageChunk } from "ai";

// Define a reusable hook type
const approvalHook = defineHook<{ approved: boolean; reason: string }>();

async function scheduleTask({ delaySeconds }: { delaySeconds: number }) {
  // Note: No "use step" for this tool call,
  // since `sleep()` is a workflow level function
  await sleep(`${delaySeconds}s`);
  return `Slept for ${delaySeconds} seconds`;
}

async function requestApproval({ message }: { message: string }) {
  // Note: No "use step" for this tool call either,
  // since hooks are awaited at the workflow level

  // Utilize a Hook for Human-in-the-loop approval
  const hook = approvalHook.create({
    metadata: { message }
  });

  console.log(`Approval needed - token: ${hook.token}`);

  // Wait for the approval payload
  const approval = await hook;

  if (approval.approved) {
    return `Request approved: ${approval.reason}`;
  } else {
    throw new Error(`Request denied: ${approval.reason}`);
  }
}

async function agentWithLibraryFeaturesWorkflow(userRequest: string) {
  "use workflow";

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    tools: {
      scheduleTask: {
        description: "Pause the workflow for the specified number of seconds",
        inputSchema: z.object({
          delaySeconds: z.number(),
        }),
        execute: scheduleTask,
      },
      requestApproval: {
        description: "Request approval for an action",
        inputSchema: z.object({ message: z.string() }),
        execute: requestApproval,
      },
    },
  });

  await agent.stream({
    messages: [{ role: "user", content: userRequest }],
    writable: getWritable<UIMessageChunk>(),
  });
}
```

### Dynamic Context with prepareStep

Use `prepareStep` to modify settings before each step in the agent loop:

```typescript
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import type { UIMessageChunk } from "ai";

async function agentWithPrepareStep(userMessage: string) {
  "use workflow";

  const agent = new DurableAgent({
    model: "openai/gpt-4.1-mini", // Default model
    instructions: "You are a helpful assistant.",
  });

  await agent.stream({
    messages: [{ role: "user", content: userMessage }],
    writable: getWritable<UIMessageChunk>(),
    prepareStep: async ({ stepNumber, messages }) => {
      // Switch to a stronger model for complex reasoning after initial steps
      if (stepNumber > 2 && messages.length > 10) {
        return {
          model: "anthropic/claude-sonnet-4.5",
        };
      }

      // Trim context if messages grow too large
      if (messages.length > 20) {
        return {
          messages: [
            messages[0], // Keep system message
            ...messages.slice(-10), // Keep last 10 messages
          ],
        };
      }

      return {}; // No changes
    },
  });
}
```

### Message Injection with prepareStep

Inject messages from external sources (like hooks) before each LLM call:

```typescript
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable, defineHook } from "workflow";
import type { UIMessageChunk } from "ai";

const messageHook = defineHook<{ message: string }>();

async function agentWithMessageQueue(initialMessage: string) {
  "use workflow";

  const messageQueue: Array<{ role: "user"; content: string }> = [];

  // Listen for incoming messages via hook
  const hook = messageHook.create();
  hook.then(({ message }) => {
    messageQueue.push({ role: "user", content: message });
  });

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    instructions: "You are a helpful assistant.",
  });

  await agent.stream({
    messages: [{ role: "user", content: initialMessage }],
    writable: getWritable<UIMessageChunk>(),
    prepareStep: ({ messages }) => {
      // Inject queued messages before the next step
      if (messageQueue.length > 0) {
        const newMessages = messageQueue.splice(0);
        return {
          messages: [
            ...messages,
            ...newMessages.map(m => ({
              role: m.role,
              content: [{ type: "text" as const, text: m.content }],
            })),
          ],
        };
      }
      return {};
    },
  });
}
```

### Generation Settings

Configure model generation parameters at the constructor or stream level:

```typescript
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import type { UIMessageChunk } from "ai";

async function agentWithGenerationSettings() {
  "use workflow";

  // Set default generation settings in constructor
  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    temperature: 0.7,
    maxOutputTokens: 2000,
    topP: 0.9,
  });

  // Override settings per-stream call
  await agent.stream({
    messages: [{ role: "user", content: "Write a creative story" }],
    writable: getWritable<UIMessageChunk>(),
    temperature: 0.9, // More creative for this call
    maxSteps: 1,
  });

  // Use different settings for a different task
  await agent.stream({
    messages: [{ role: "user", content: "Summarize this document precisely" }],
    writable: getWritable<UIMessageChunk>(),
    temperature: 0.1, // More deterministic
    maxSteps: 1,
  });
}
```

### Limiting Steps with maxSteps

By default, the agent loops until completion. Use `maxSteps` to limit the number of LLM calls:

```typescript
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import { z } from "zod";
import type { UIMessageChunk } from "ai";

async function searchWeb({ query }: { query: string }) {
  "use step";
  return `Results for "${query}": ...`;
}

async function analyzeResults({ data }: { data: string }) {
  "use step";
  return `Analysis: ${data}`;
}

async function multiStepAgent() {
  "use workflow";

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    tools: {
      searchWeb: {
        description: "Search the web for information",
        inputSchema: z.object({ query: z.string() }),
        execute: searchWeb,
      },
      analyzeResults: {
        description: "Analyze search results",
        inputSchema: z.object({ data: z.string() }),
        execute: analyzeResults,
      },
    },
  });

  // Limit to 10 steps for safety on complex research tasks
  const result = await agent.stream({
    messages: [{ role: "user", content: "Research the latest AI trends and provide an analysis" }],
    writable: getWritable<UIMessageChunk>(),
    maxSteps: 10,
  });

  // Access step-by-step details
  console.log(`Completed in ${result.steps.length} steps`);
}
```

### Callbacks for Monitoring

Use callbacks to monitor streaming progress, handle errors, and react to completion:

```typescript
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import type { UIMessageChunk } from "ai";

async function agentWithCallbacks() {
  "use workflow";

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
  });

  await agent.stream({
    messages: [{ role: "user", content: "Hello!" }],
    writable: getWritable<UIMessageChunk>(),
    maxSteps: 5,

    // Called after each step completes
    onStepFinish: async (step) => {
      console.log(`Step finished: ${step.finishReason}`);
      console.log(`Tokens used: ${step.usage.totalTokens}`);
    },

    // Called when streaming completes
    onFinish: async ({ steps, messages }) => {
      console.log(`Completed with ${steps.length} steps`);
      console.log(`Final message count: ${messages.length}`);
    },

    // Called on errors
    onError: async ({ error }) => {
      console.error("Stream error:", error);
    },
  });
}
```

### Structured Output

Parse structured data from the LLM response using `Output.object`:

```typescript
import { DurableAgent, Output } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import { z } from "zod";
import type { UIMessageChunk } from "ai";

async function agentWithStructuredOutput() {
  "use workflow";

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
  });

  const result = await agent.stream({
    messages: [{ role: "user", content: "Analyze the sentiment of: 'I love this product!'" }],
    writable: getWritable<UIMessageChunk>(),
    experimental_output: Output.object({
      schema: z.object({
        sentiment: z.enum(["positive", "negative", "neutral"]),
        confidence: z.number().min(0).max(1),
        reasoning: z.string(),
      }),
    }),
  });

  // Access the parsed structured output
  console.log(result.experimental_output);
  // { sentiment: "positive", confidence: 0.95, reasoning: "..." }
}
```

### Tool Choice Control

Control when and which tools the model can use:

```typescript
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import { z } from "zod";
import type { UIMessageChunk } from "ai";

async function agentWithToolChoice() {
  "use workflow";

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    tools: {
      calculator: {
        description: "Perform calculations",
        inputSchema: z.object({ expression: z.string() }),
        execute: async ({ expression }) => `Calculated: ${expression}`,
      },
      search: {
        description: "Search for information",
        inputSchema: z.object({ query: z.string() }),
        execute: async ({ query }) => `Results for: ${query}`,
      },
    },
    toolChoice: "auto", // Default: model decides
  });

  // Force the model to use a tool
  await agent.stream({
    messages: [{ role: "user", content: "What is 2 + 2?" }],
    writable: getWritable<UIMessageChunk>(),
    toolChoice: "required",
    maxSteps: 2,
  });

  // Prevent tool usage
  await agent.stream({
    messages: [{ role: "user", content: "Just chat with me" }],
    writable: getWritable<UIMessageChunk>(),
    toolChoice: "none",
  });

  // Force a specific tool
  await agent.stream({
    messages: [{ role: "user", content: "Calculate something" }],
    writable: getWritable<UIMessageChunk>(),
    toolChoice: { type: "tool", toolName: "calculator" },
    maxSteps: 2,
  });

  // Limit available tools for this call
  await agent.stream({
    messages: [{ role: "user", content: "Just search, don't calculate" }],
    writable: getWritable<UIMessageChunk>(),
    activeTools: ["search"],
    maxSteps: 2,
  });
}
```

### Passing Context to Tools

Use `experimental_context` to pass shared context to tool executions:

```typescript
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import { z } from "zod";
import type { UIMessageChunk } from "ai";

interface UserContext {
  userId: string;
  permissions: string[];
}

async function agentWithContext(userId: string) {
  "use workflow";

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    tools: {
      getUserData: {
        description: "Get user data",
        inputSchema: z.object({}),
        execute: async (_, { experimental_context }) => {
          const ctx = experimental_context as UserContext;
          return { userId: ctx.userId, permissions: ctx.permissions };
        },
      },
    },
  });

  await agent.stream({
    messages: [{ role: "user", content: "What are my permissions?" }],
    writable: getWritable<UIMessageChunk>(),
    maxSteps: 2,
    experimental_context: {
      userId,
      permissions: ["read", "write"],
    } as UserContext,
  });
}
```

### Collecting UI Messages

Use `collectUIMessages` to accumulate `UIMessage[]` during streaming. This is useful when you need to persist the conversation without re-reading the run's output stream:

```typescript lineNumbers
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import type { UIMessage, UIMessageChunk } from "ai";

async function agentWithUIMessages(userMessage: string) {
  "use workflow";

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    instructions: "You are a helpful assistant.",
  });

  const result = await agent.stream({
    messages: [{ role: "user", content: userMessage }],
    writable: getWritable<UIMessageChunk>(),
    collectUIMessages: true, // [!code highlight]
  });

  // Access the accumulated UI messages
  const uiMessages: UIMessage[] = result.uiMessages ?? []; // [!code highlight]

  // Persist messages to a database
  await saveConversation(uiMessages);

  return result;
}

async function saveConversation(messages: UIMessage[]) {
  "use step";
  // Save to database...
}
```

<Callout type="info">
  The `uiMessages` property is only available when `collectUIMessages` is set to `true`. When disabled, `uiMessages` is `undefined`.
</Callout>

### Machine-Readable Tool Results

`stream()` returns tool call information you can inspect programmatically. Compare `toolCalls` with `toolResults` to find unresolved tool calls that need client-side handling:

```typescript lineNumbers
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import { z } from "zod";
import type { UIMessageChunk } from "ai";

async function checkOrderStatus({ orderId }: { orderId: string }) {
  "use step";
  return `Order ${orderId}: shipped`;
}

async function agentWithToolInspection(userMessage: string) {
  "use workflow";

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    tools: {
      checkOrderStatus: {
        description: "Check order status",
        inputSchema: z.object({ orderId: z.string() }),
        execute: checkOrderStatus,
      },
    },
  });

  const result = await agent.stream({
    messages: [{ role: "user", content: userMessage }],
    writable: getWritable<UIMessageChunk>(),
  });

  const unresolved = result.toolCalls.filter( // [!code highlight]
    (tc) => !result.toolResults.some((tr) => tr.toolCallId === tc.toolCallId) // [!code highlight]
  ); // [!code highlight]

  if (unresolved.length > 0) {
    return {
      status: "needs-client-tools",
      unresolved,
    };
  }

  return {
    status: "complete",
    messages: result.messages,
    toolResults: result.toolResults,
  };
}
```

<Callout type="info">
  `toolCalls` and `toolResults` reflect the *last step* of the agent loop. Tools without an `execute` function will appear in `toolCalls` but not in `toolResults`, which is how you detect calls that need client-side handling.
</Callout>

### Aborting Long-Running Streams

Use `timeout` to abort a stream automatically after a fixed duration:

<Callout type="warn">
  `abortSignal` is not yet supported and will be available in a future release. Use `timeout` for now.
</Callout>

```typescript lineNumbers
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import type { UIMessageChunk } from "ai";

async function agentWithTimeout(userMessage: string) {
  "use workflow";

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
  });

  await agent.stream({
    messages: [{ role: "user", content: userMessage }],
    writable: getWritable<UIMessageChunk>(),
    timeout: 30_000, // [!code highlight]
  });
}
```

## See Also

* [Building Durable AI Agents](/docs/ai) - Complete guide to creating durable agents
* [Queueing User Messages](/docs/ai/message-queueing) - Using prepareStep for message injection
* [WorkflowChatTransport](/docs/api-reference/workflow-ai/workflow-chat-transport) - Transport layer for AI SDK streams
* [Workflows and Steps](/docs/foundations/workflows-and-steps) - Understanding workflow fundamentals
* [AI SDK Loop Control](https://ai-sdk.dev/docs/agents/loop-control) - AI SDK's agent loop control patterns


---
title: @workflow/ai
description: Helpers for building AI-powered workflows with the AI SDK.
type: overview
summary: Explore helpers for integrating AI SDK to build durable AI-powered workflows.
related:
  - /docs/ai
---

# @workflow/ai



Helpers for integrating AI SDK for building AI-powered workflows.

## Classes

<Cards>
  <Card title="DurableAgent" href="/docs/api-reference/workflow-ai/durable-agent">
    Deprecated — use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent). Reference kept for existing `@workflow/ai/agent` imports.
  </Card>

  <Card title="WorkflowChatTransport" href="/docs/api-reference/workflow-ai/workflow-chat-transport">
    Deprecated — use AI SDK's [`WorkflowChatTransport`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) from `@ai-sdk/workflow`. Reference kept for existing `@workflow/ai` imports.
  </Card>
</Cards>


---
title: WorkflowChatTransport
description: Chat transport with automatic reconnection and recovery from interrupted streams.
type: reference
summary: Use WorkflowChatTransport as a drop-in AI SDK transport for automatic stream reconnection.
prerequisites:
  - /docs/ai
related:
  - /docs/ai/resumable-streams
---

# WorkflowChatTransport



<Callout type="warn">
  `WorkflowChatTransport` from `@workflow/ai` is deprecated. AI SDK ships a 1:1 port — use [`WorkflowChatTransport` from `@ai-sdk/workflow`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) instead. This reference is kept for existing applications that still import it from `@workflow/ai`.
</Callout>

A chat transport implementation for the AI SDK that provides reliable message streaming with automatic reconnection to interrupted streams. This transport is a drop-in replacement for the default AI SDK transport, enabling seamless recovery from network issues, page refreshes, or Vercel Function timeouts.

<Callout>
  `WorkflowChatTransport` implements the [`ChatTransport`](https://ai-sdk.dev/docs/ai-sdk-ui/transport) interface from the AI SDK and is designed to work with workflow-based chat applications. It requires endpoints that return the `x-workflow-run-id` header to enable stream resumption.
</Callout>

```typescript lineNumbers
import { useChat } from "@ai-sdk/react";
import { WorkflowChatTransport } from "@workflow/ai";

export default function Chat() {
  const { messages, sendMessage } = useChat({
    transport: new WorkflowChatTransport(),
  });

  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>{m.content}</div>
      ))}
    </div>
  );
}
```

## API Signature

### Class

<TSDoc
  definition={`
import { WorkflowChatTransport } from "@workflow/ai";
export default WorkflowChatTransport;`}
/>

### WorkflowChatTransportOptions

<TSDoc
  definition={`
import type { WorkflowChatTransportOptions } from "@workflow/ai";
export default WorkflowChatTransportOptions;`}
/>

## Key Features

* **Automatic Reconnection**: Automatically recovers from interrupted streams with configurable retry limits
* **Workflow Integration**: Seamlessly works with workflow-based endpoints that provide the `x-workflow-run-id` header
* **Customizable Requests**: Allows intercepting and modifying requests via `prepareSendMessagesRequest` and `prepareReconnectToStreamRequest`
* **Stream Callbacks**: Provides hooks for tracking chat lifecycle via `onChatSendMessage` and `onChatEnd`
* **Custom Fetch**: Supports custom fetch implementations for advanced use cases

## Good to Know

* The transport expects chat endpoints to return the `x-workflow-run-id` header in the response to enable stream resumption
* By default, the transport posts to `/api/chat` and reconnects via `/api/chat/{runId}/stream`
* The `onChatSendMessage` callback receives the full response object, allowing you to extract and store the workflow run ID for session resumption
* Stream interruptions are automatically detected when a "finish" chunk is not received in the initial response
* The `maxConsecutiveErrors` option controls how many reconnection attempts are made before giving up (default: 3)
* `initialStartIndex` (constructor option) sets the default chunk position for the **first** reconnection attempt (e.g. after a page refresh). Subsequent retries within the same reconnection loop always resume from the last received chunk. Negative values (e.g. `-20`) read from the end of the stream, which is useful for showing only recent output without replaying the full conversation. `startIndex` (per-call option on `reconnectToStream`) overrides `initialStartIndex` for a single reconnection
* When using a negative `initialStartIndex`, the reconnection endpoint must return the `x-workflow-stream-tail-index` response header (via `readable.getTailIndex()`). The transport reads this header to compute absolute chunk positions for retries. Without it, startIndex is assumed to be 0, replaying the entire stream

## Examples

### Basic Chat Setup

```typescript
"use client";

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

export default function BasicChat() {
  const [input, setInput] = useState("");
  const { messages, sendMessage } = useChat({
    transport: new WorkflowChatTransport(),
  });

  return (
    <div>
      <div className="space-y-4">
        {messages.map((m) => (
          <div key={m.id}>
            <strong>{m.role}:</strong> {m.content}
          </div>
        ))}
      </div>

      <form
        onSubmit={(e) => {
          e.preventDefault();
          sendMessage({ text: input });
          setInput("");
        }}
      >
        <input
          value={input}
          placeholder="Say something..."
          onChange={(e) => setInput(e.currentTarget.value)}
        />
      </form>
    </div>
  );
}
```

### With Session Persistence and Resumption

```typescript
"use client";

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

export default function ChatWithResumption() {
  const [input, setInput] = useState("");
  const activeWorkflowRunId = useMemo(() => {
    if (typeof window === "undefined") return;
    return localStorage.getItem("active-workflow-run-id") ?? undefined;
  }, []);

  const { messages, sendMessage } = useChat({
    resume: !!activeWorkflowRunId,
    transport: new WorkflowChatTransport({
      onChatSendMessage: (response, options) => {
        // Save chat history to localStorage
        localStorage.setItem(
          "chat-history",
          JSON.stringify(options.messages)
        );

        // Extract and store the workflow run ID for session resumption
        const workflowRunId = response.headers.get("x-workflow-run-id");
        if (workflowRunId) {
          localStorage.setItem("active-workflow-run-id", workflowRunId);
        }
      },
      onChatEnd: ({ chatId, chunkIndex }) => {
        console.log(`Chat ${chatId} completed with ${chunkIndex} chunks`);
        // Clear the active run ID when chat completes
        localStorage.removeItem("active-workflow-run-id");
      },
    }),
  });

  return (
    <div>
      <div className="space-y-4">
        {messages.map((m) => (
          <div key={m.id}>
            <strong>{m.role}:</strong> {m.content}
          </div>
        ))}
      </div>

      <form
        onSubmit={(e) => {
          e.preventDefault();
          sendMessage({ text: input });
          setInput("");
        }}
      >
        <input
          value={input}
          placeholder="Say something..."
          onChange={(e) => setInput(e.currentTarget.value)}
        />
      </form>
    </div>
  );
}
```

### With Custom Request Configuration

```typescript
"use client";

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

export default function ChatWithCustomConfig() {
  const [input, setInput] = useState("");
  const { messages, sendMessage } = useChat({
    transport: new WorkflowChatTransport({
      prepareSendMessagesRequest: async (config) => {
        return {
          ...config,
          api: "/api/chat",
          headers: {
            ...config.headers,
            "Authorization": `Bearer ${process.env.NEXT_PUBLIC_API_TOKEN}`,
            "X-Custom-Header": "custom-value",
          },
          credentials: "include",
        };
      },
      prepareReconnectToStreamRequest: async (config) => {
        return {
          ...config,
          headers: {
            ...config.headers,
            "Authorization": `Bearer ${process.env.NEXT_PUBLIC_API_TOKEN}`,
          },
          credentials: "include",
        };
      },
      maxConsecutiveErrors: 5,
    }),
  });

  return (
    <div>
      <div className="space-y-4">
        {messages.map((m) => (
          <div key={m.id}>
            <strong>{m.role}:</strong> {m.content}
          </div>
        ))}
      </div>

      <form
        onSubmit={(e) => {
          e.preventDefault();
          sendMessage({ text: input });
          setInput("");
        }}
      >
        <input
          value={input}
          placeholder="Say something..."
          onChange={(e) => setInput(e.currentTarget.value)}
        />
      </form>
    </div>
  );
}
```

## Mid-part resumes

A workflow stream is a flat sequence of chunks, but the AI SDK's UI protocol groups chunks into logical parts: a `text-start` opens a text part that subsequent `text-delta`s extend and a `text-end` closes, and the same shape applies to `reasoning-*` and `tool-input-*`. The AI SDK client enforces that grammar — a `reasoning-delta` whose `reasoning-start` was never seen throws and breaks the chat.

A non-zero `startIndex` (in particular a negative `initialStartIndex`) resolves to a chunk offset with no awareness of those part boundaries, so it can land in the middle of an open part. When that happens, `WorkflowChatTransport` will **drop chunks that reference a part it didn't see a start for** and log a one-time warning. The chat keeps working, but any partial part overlapping the resume cursor is discarded. Tool calls are an exception: `tool-input-available` / `tool-input-error` chunks are self-contained (they carry the full input), so a tool call is recovered as soon as one of those chunks appears in the resumed window — only its streamed input deltas are lost.

To preserve those partial parts, rewind to a step boundary on the server before returning the readable. `start-step` / `finish-step` chunks are the natural seams — no UI part is ever open across them. Sketch:

{/*@skip-typecheck: incomplete code sample*/}

```typescript title="app/api/chat/[id]/stream/route.ts"
const run = getRun(id);
const tailIndex = await run.getReadable().getTailIndex();

let resolved = startIndex < 0
  ? Math.max(0, tailIndex + 1 + startIndex)
  : startIndex;

if (startIndex !== 0) {
  // Walk back from `resolved` to the most recent start-step (or chunk 0),
  // capping the lookback so a single huge step can't trigger an unbounded scan.
  const LOOKBACK = 200;
  const probe = run.getReadable({ startIndex: Math.max(0, resolved - LOOKBACK) });
  let i = Math.max(0, resolved - LOOKBACK);
  let lastBoundary = i;
  for await (const chunk of probe as unknown as AsyncIterable<{ type: string }>) {
    if (i >= resolved) break;
    if (chunk.type === "start-step") lastBoundary = i;
    i++;
  }
  resolved = lastBoundary;
}

return createUIMessageStreamResponse({
  stream: run.getReadable({ startIndex: resolved }),
  headers: { "x-workflow-stream-tail-index": String(tailIndex) },
});
```

## See Also

* [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) - Building durable, resumable AI agents (replaces `DurableAgent`)
* [AI SDK `useChat` Documentation](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) - Using `useChat` with custom transports
* [Workflows and Steps](/docs/foundations/workflows-and-steps) - Understanding workflow fundamentals
* ["flight-booking-app" Example](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) - An example application which uses `WorkflowChatTransport`


---
title: createHook
description: Create a low-level hook to resume workflows with arbitrary payloads.
type: reference
summary: Use createHook to pause a workflow and resume it with an arbitrary payload from an external system.
prerequisites:
  - /docs/foundations/hooks
related:
  - /docs/api-reference/workflow/define-hook
  - /docs/api-reference/workflow/create-webhook
  - /docs/foundations/idempotency
---

# createHook



Creates a low-level hook primitive that can be used to resume a workflow run with arbitrary payloads.

Hooks allow external systems to send data to a paused workflow without the HTTP-specific constraints of webhooks. They're identified by a token and can receive any serializable payload.

```ts lineNumbers
import { createHook } from "workflow"

export async function hookWorkflow() {
  "use workflow";
  // `using` automatically disposes the hook when it goes out of scope
  using hook = createHook();  // [!code highlight]
  const result = await hook; // Suspends the workflow until the hook is resumed
}
```

## API Signature

### Parameters

<TSDoc
  definition={`
import { createHook } from "workflow";
export default createHook;`
}
  showSections={['parameters']}
/>

#### HookOptions

<TSDoc
  definition={`
import type { HookOptions } from "workflow";
export default HookOptions;`
}
/>

### Returns

<TSDoc
  definition={`
import { createHook } from "workflow";
export default createHook;`}
  showSections={['returns']}
/>

#### Hook

<TSDoc
  definition={`
import type { Hook } from "workflow";
export default Hook;`}
/>

The returned `Hook` object also implements `AsyncIterable<T>`, which allows you to iterate over incoming payloads using `for await...of` syntax.

Use `hook.getConflict()` (available starting in `workflow@4.5.0`) to check whether the hook token is already claimed by another active hook, without waiting for hook payload data. Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the registration, then resolves with `null` once `hook_created` is recorded, or with `{ runId }` identifying the conflicting run if another active hook already owns the same token.

## Examples

### Basic Usage

When creating a hook, you can specify a payload type for automatic type safety:

```typescript lineNumbers
import { createHook } from "workflow"

export async function approvalWorkflow() {
  "use workflow";

  using hook = createHook<{ approved: boolean; comment: string }>(); // [!code highlight]
  console.log("Send approval to token:", hook.token);

  const result = await hook;

  if (result.approved) {
    console.log("Approved with comment:", result.comment);
  }
}
```

### Customizing Tokens

Tokens are used to identify a specific hook. You can customize the token to be more specific to a use case.

```typescript lineNumbers
import { createHook } from "workflow";

export async function slackBotWorkflow(channelId: string) {
  "use workflow";

  // Token constructed from channel ID
  using hook = createHook<SlackMessage>({ // [!code highlight]
    token: `slack_messages:${channelId}`, // [!code highlight]
  }); // [!code highlight]

  for await (const message of hook) {
    if (message.text === "/stop") {
      break;
    }
    await processMessage(message);
  }
}
```

### Detecting Token Conflicts

Use `hook.getConflict()` (available starting in `workflow@4.5.0`) when the workflow needs to claim a hook token before doing other work, but does not need a payload yet:

```typescript lineNumbers
import { createHook } from "workflow";

declare function chargeOrder(orderId: string): Promise<void>; // @setup

async function processOrder(orderId: string) {
  "use workflow";

  using hook = createHook({ // [!code highlight]
    token: `order:${orderId}` // [!code highlight]
  }); // [!code highlight]

  const conflict = await hook.getConflict(); // [!code highlight]
  if (conflict) { // [!code highlight]
    // Another active workflow run already owns this token.
    return { dedupedTo: conflict.runId };
  }

  await chargeOrder(orderId);
}
```

Because `createHook()` alone does not suspend the workflow, awaiting `hook.getConflict()` is what actually suspends the run and commits the hook registration. It only waits for registration — to receive payload data from a future `resumeHook()` call, await the hook itself or iterate it with `for await...of`.

On a conflict, the resolved value is `{ runId }` identifying the run that currently owns the token. To act on the owner — inspect its status, wait for its result, or cancel it — pass `conflict.runId` to [`getRun()`](/docs/api-reference/workflow-api/get-run) inside a step. See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies in context.

<Callout type="info">
  Custom hook tokens are the recommended way to coordinate active workflow runs. Use a deterministic token from your domain, such as an order ID or conversation ID, create the hook near the beginning of the workflow, and check `await hook.getConflict()` before work that depends on owning the token. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
</Callout>

### Waiting for Multiple Payloads

You can also wait for multiple payloads by using the `for await...of` syntax.

```typescript lineNumbers
import { createHook } from "workflow"

export async function collectHookWorkflow() {
  "use workflow";

  using hook = createHook<{ message: string; done?: boolean }>();

  const payloads = [];
  for await (const payload of hook) { // [!code highlight]
    payloads.push(payload);

    if (payload.done) break;
  }

  return payloads;
}
```

### Disposing Hooks Early

You can dispose a hook early to release its token for reuse by another workflow. This is useful for handoff patterns where one workflow needs to transfer a hook token to another workflow while still running.

```typescript lineNumbers
import { createHook } from "workflow"

export async function handoffWorkflow(channelId: string) {
  "use workflow";

  const hook = createHook<{ message: string; handoff?: boolean }>({
    token: `channel:${channelId}`
  });

  for await (const payload of hook) {
    console.log("Received:", payload.message);

    if (payload.handoff) {
      hook.dispose(); // [!code highlight] Release the token for another workflow
      break;
    }
  }

  // Continue with other work while another workflow uses the token
}
```

After calling `dispose()`, the hook will no longer receive events and its token becomes available for other workflows to use.

### Automatic Disposal with `using`

Hooks implement the [TC39 Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management) proposal, allowing automatic disposal with the `using` keyword:

```typescript lineNumbers
import { createHook } from "workflow"

export async function scopedHookWorkflow(channelId: string) {
  "use workflow";

  {
    using hook = createHook<{ message: string }>({ // [!code highlight]
      token: `channel:${channelId}`
    });

    const payload = await hook;
    console.log("Received:", payload.message);
  } // hook is automatically disposed here // [!code highlight]

  // Token is now available for other workflows to use
  console.log("Hook disposed, continuing with other work...");
}
```

This is equivalent to manually calling `dispose()` but ensures the hook is always cleaned up, even if an error occurs.

## Related Functions

* [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper
* [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with a payload
* [`createWebhook()`](/docs/api-reference/workflow/create-webhook) - Higher-level HTTP webhook abstraction
* [Idempotency](/docs/foundations/idempotency) - Deduplicate step side effects and workflow starts


---
title: createWebhook
description: Create webhooks to suspend and resume workflows via HTTP requests.
type: reference
summary: Use createWebhook to suspend a workflow until an HTTP request is received at a generated URL.
prerequisites:
  - /docs/foundations/hooks
related:
  - /docs/api-reference/workflow/create-hook
---

# createWebhook



Creates a webhook that can be used to suspend and resume a workflow run upon receiving an HTTP request.

Webhooks provide a way for external systems to send HTTP requests directly to your workflow. Unlike hooks which accept arbitrary payloads, webhooks work with standard HTTP `Request` objects and can return HTTP `Response` objects.

<Callout type="warn">
  `createWebhook()` creates a public endpoint at `/.well-known/workflow/v1/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests resuming that webhook. This is convenient for prototypes and simple resume links because it avoids creating another route, but if you need stronger security, prefer [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the request before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) to avoid unauthenticated workflow resumptions.
</Callout>

```ts lineNumbers
import { createWebhook } from "workflow"

export async function webhookWorkflow() {
  "use workflow";
  // `using` automatically disposes the webhook when it goes out of scope
  using webhook = createWebhook();  // [!code highlight]
  console.log("Webhook URL:", webhook.url);

  const request = await webhook; // Suspends until HTTP request received
  console.log("Received request:", request.method, request.url);
}
```

## API Signature

### Parameters

<TSDoc
  definition={`
import { createWebhook } from "workflow";
export default createWebhook;`}
  showSections={['parameters']}
/>

### Returns

<TSDoc
  definition={`
import { createWebhook } from "workflow";
export default createWebhook;`}
  showSections={['returns']}
/>

The returned `Webhook` object has:

* `url`: The HTTP endpoint URL that external systems can call
* `token`: The unique token identifying this webhook
* `getConflict()`: A promise that resolves with `{ runId }` identifying the conflicting run if another active hook already owns this token, or `null` once the webhook endpoint has been registered
* Implements `AsyncIterable<T>` for handling multiple requests, where `T` is `Request` (default) or `RequestWithResponse` (manual mode)

When using `createWebhook({ respondWith: 'manual' })`, the resolved request type is `RequestWithResponse`, which extends the standard `Request` interface with a `respondWith(response: Response): Promise<void>` method for sending custom responses back to the caller.

<Callout type="info">
  Use the simplest option that satisfies the prompt:

  * `createWebhook()` — generated callback URL, and the default `202 Accepted` response is fine
  * `createWebhook({ respondWith: 'manual' })` — generated callback URL, but you must send a custom body, status, or headers
  * `createHook()` + `resumeHook()` — the app resumes from server-side code with a deterministic business token instead of a generated callback URL
</Callout>

<details>
  <summary>
    Common wrong turns
  </summary>

  * Do not use `respondWith: 'manual'` just because the flow has a callback URL.
  * Do not use `RequestWithResponse` unless you chose manual mode.
  * Do not invent a custom callback route when `webhook.url` is the intended callback surface.
</details>

## Examples

### Basic Usage

Create a webhook that receives HTTP requests and logs the request details:

```typescript lineNumbers
import { createWebhook } from "workflow"

export async function basicWebhookWorkflow() {
  "use workflow";

  using webhook = createWebhook(); // [!code highlight]
  console.log("Send requests to:", webhook.url);

  const request = await webhook;

  console.log("Method:", request.method);
  console.log("Headers:", Object.fromEntries(request.headers));

  const body = await request.text();
  console.log("Body:", body);
}
```

### Responding to Webhook Requests (Manual Mode)

Use this section only when the caller requires a non-default HTTP response. If `202 Accepted` is acceptable, use `createWebhook()` without `respondWith: "manual"`.

Pass `{ respondWith: "manual" }` to get a `RequestWithResponse` object with a `respondWith()` method. Note that `respondWith()` must be called from within a step function:

```typescript lineNumbers
import { createWebhook, type RequestWithResponse } from "workflow"

async function sendResponse(request: RequestWithResponse): Promise<void> {
  "use step";
  await request.respondWith(
    new Response(JSON.stringify({ success: true, message: "Received!" }), {
      status: 200,
      headers: { "Content-Type": "application/json" }
    })
  );
}

export async function respondingWebhookWorkflow() {
  "use workflow";

  using webhook = createWebhook({ respondWith: "manual" });
  console.log("Webhook URL:", webhook.url);

  const request = await webhook;

  // Send a custom response back to the caller
  await sendResponse(request);

  // Continue workflow processing
  const data = await request.json();
  await processData(data);
}

async function processData(data: any): Promise<void> {
  "use step";
  // Process the webhook data
  console.log("Processing:", data);
}
```

### Waiting for Multiple Requests

You can also wait for multiple requests by using the `for await...of` syntax.

```typescript lineNumbers
import { createWebhook, type RequestWithResponse } from "workflow"

async function sendAck(request: RequestWithResponse, message: string) {
  "use step";
  await request.respondWith(
    Response.json({ received: true, message })
  );
}

async function processEvent(data: any) {
  "use step";
  console.log("Processing event:", data);
}

export async function eventCollectorWorkflow() {
  "use workflow";

  using webhook = createWebhook({ respondWith: "manual" });
  console.log("Send events to:", webhook.url);

  for await (const request of webhook) { // [!code highlight]
    const data = await request.json();

    if (data.type === "done") {
      await sendAck(request, "Workflow complete");
      break;
    }

    await sendAck(request, "Event received");
    await processEvent(data);
  }
}
```

## Related Functions

* [`createHook()`](/docs/api-reference/workflow/create-hook) — Use when the app resumes from server-side code with a deterministic business token.
* [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) — Pairs with `createHook()` for deterministic server-side resume.
* [`defineHook()`](/docs/api-reference/workflow/define-hook) — Type-safe hook helper.
* [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) — Low-level runtime API. Most integrations should call `webhook.url` directly instead of adding a custom callback route.


---
title: defineHook
description: Create type-safe hooks with consistent payload types and optional validation.
type: reference
summary: Use defineHook to create a reusable, type-safe hook definition with optional schema validation.
prerequisites:
  - /docs/foundations/hooks
related:
  - /docs/api-reference/workflow/create-hook
---

# defineHook



Creates a type-safe hook helper that ensures the payload type is consistent between hook creation and resumption.

This is a lightweight wrapper around [`createHook()`](/docs/api-reference/workflow/create-hook) and [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) to avoid type mismatches. It also supports optional runtime validation and transformation of payloads using any [Standard Schema v1](https://standardschema.dev) compliant validator like Zod or Valibot.

<Callout>
  We recommend using `defineHook()` over `createHook()` in production codebases for better type safety and optional runtime validation.
</Callout>

```ts lineNumbers
import { defineHook } from "workflow";

const nameHook = defineHook<{
  name: string;
}>();

export async function nameWorkflow() {
  "use workflow";

  const hook = nameHook.create();  // [!code highlight]
  const result = await hook; // Fully typed as { name: string }
  console.log("Name:", result.name);
}
```

## API Signature

### Parameters

<TSDoc
  definition={`
import { defineHook } from "workflow";
export default defineHook;`}
  showSections={['parameters']}
/>

### Returns

<TSDoc
  definition={`
interface DefineHook<T> {
/**

* Creates a new hook with the defined payload type.
*/
create: (options?: HookOptions) => Hook<T>;

/**

* Resumes a hook by sending a payload with the defined type.
 */
resume: (token: string, payload: T) => Promise<HookEntity | null>;
}
export default DefineHook;`}
/>

## Examples

### Basic Type-Safe Hook Definition

By defining the hook once with a specific payload type, you can reuse it in multiple workflows and API routes with automatic type safety.

```typescript lineNumbers
import { defineHook } from "workflow";

// Define once with a specific payload type
const approvalHook = defineHook<{ // [!code highlight]
  approved: boolean; // [!code highlight]
  comment: string; // [!code highlight]
}>(); // [!code highlight]

// In your workflow
export async function workflowWithApproval() {
  "use workflow";

  const hook = approvalHook.create();
  const result = await hook; // Fully typed as { approved: boolean; comment: string }

  console.log("Approved:", result.approved);
  console.log("Comment:", result.comment);
}
```

### Resuming with Type Safety

Hooks can be resumed using the same defined hook and a token. By using the same hook, you can ensure that the payload matches the defined type when resuming a hook.

```typescript lineNumbers
// Use the same defined hook to resume
export async function POST(request: Request) {
  const { token, approved, comment } = await request.json();

  // Type-safe resumption - TypeScript ensures the payload matches
  const result = await approvalHook.resume(token, { // [!code highlight]
    approved, // [!code highlight]
    comment, // [!code highlight]
  }); // [!code highlight]

  if (!result) {
    return Response.json({ error: "Hook not found" }, { status: 404 });
  }

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

### Validate and Transform with Schema

You can provide runtime validation and transformation of hook payloads using the `schema` option. This option accepts any validator that conforms to the [Standard Schema v1](https://standardschema.dev) specification.

<Callout type="info">
  Standard Schema is a standardized specification for schema validation libraries. Most popular validation libraries support it, including Zod, Valibot, ArkType, and Effect Schema. You can also write custom validators.
</Callout>

#### Using Zod with defineHook

Here's an example using [Zod](https://zod.dev) to validate and transform hook payloads:

```typescript lineNumbers
import { defineHook } from "workflow";
import { z } from "zod";

export const approvalHook = defineHook({
  schema: z.object({ // [!code highlight]
    approved: z.boolean(), // [!code highlight]
    comment: z.string().min(1).transform((value) => value.trim()), // [!code highlight]
  }), // [!code highlight]
});

export async function approvalWorkflow(approvalId: string) {
  "use workflow";

  const hook = approvalHook.create({
    token: `approval:${approvalId}`,
  });

  // Payload is automatically typed based on the schema
  const { approved, comment } = await hook;
  console.log("Approved:", approved);
  console.log("Comment (trimmed):", comment);
}
```

When resuming the hook from an API route, the schema validates and transforms the incoming payload before the workflow resumes:

```typescript lineNumbers
export async function POST(request: Request) {
  // Incoming payload: { token: "...", approved: true, comment: "   Ready!   " }
  const { token, approved, comment } = await request.json();

  // The schema validates and transforms the payload:
  // - Checks that `approved` is a boolean
  // - Checks that `comment` is a non-empty string
  // - Trims whitespace from the comment
  // If validation fails, an error is thrown and the hook is not resumed
  await approvalHook.resume(token, { // [!code highlight]
    approved, // [!code highlight]
    comment, // Automatically trimmed to "Ready!" // [!code highlight]
  }); // [!code highlight]

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

#### Using Other Standard Schema Libraries

The same pattern works with any Standard Schema v1 compliant library. Here's an example with [Valibot](https://valibot.dev):

```typescript lineNumbers
import { defineHook } from "workflow";
import * as v from "valibot";

export const approvalHook = defineHook({
  schema: v.object({ // [!code highlight]
    approved: v.boolean(), // [!code highlight]
    comment: v.pipe(v.string(), v.minLength(1), v.trim()), // [!code highlight]
  }), // [!code highlight]
});
```

### Customizing Tokens

Tokens are used to identify a specific hook and for resuming a hook. You can customize the token to be more specific to a use case.

```typescript lineNumbers
import { defineHook } from "workflow";

const slackHook = defineHook<{ text: string; userId: string }>();

export async function slackBotWorkflow(channelId: string) {
  "use workflow";

  const hook = slackHook.create({
    token: `slack:${channelId}`, // [!code highlight]
  });

  const message = await hook;
  console.log(`Message from ${message.userId}: ${message.text}`);
}
```

## Related Functions

* [`createHook()`](/docs/api-reference/workflow/create-hook) - Create a hook in a workflow.
* [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with a payload.


---
title: FatalError
description: Throw to mark a step as permanently failed without retrying.
type: reference
summary: Throw FatalError in a step to mark it as permanently failed and prevent retries.
prerequisites:
  - /docs/foundations/errors-and-retries
related:
  - /docs/api-reference/workflow/retryable-error
---

# FatalError



When a `FatalError` is thrown in a step, it indicates that the workflow should not retry a step, marking it as failure.

You should use this when you don't want a specific step to retry.

```typescript lineNumbers
import { FatalError } from "workflow"

async function fallibleWorkflow() {
    "use workflow"
    await fallibleStep();
}

async function fallibleStep() {
    "use step"
    throw new FatalError("Fallible!") // [!code highlight]
}
```

## API Signature

### Parameters

<TSDoc
  definition={`
interface Error {
/**

* The error message.
 */
message: string;
}
export default Error;`}
/>


---
title: fetch
description: Make HTTP requests from workflows with automatic serialization and retry semantics.
type: reference
summary: Use the workflow-aware fetch to make HTTP requests with automatic serialization and retry semantics.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/errors/fetch-in-workflow
  - /docs/foundations/idempotency
---

# fetch



Makes HTTP requests from within a workflow. This is a special step function that wraps the standard `fetch` API, automatically handling serialization and providing retry semantics.

This is useful when you need to call external APIs or services from within your workflow.

<Callout type="warn">
  Because workflow `fetch()` has retry semantics, use idempotency keys when the request mutates an external system, such as creating a charge, sending an email, or enqueueing work. See [Idempotency](/docs/foundations/idempotency).
</Callout>

<Callout>
  `fetch` is a *special* type of step function provided and should be called directly inside workflow functions.
</Callout>

```typescript lineNumbers
import { fetch } from "workflow"

async function apiWorkflow() {
    "use workflow"

    // Fetch data from an API
    const response = await fetch("https://api.example.com/data") // [!code highlight]
    return await response.json()
}
```

## API Signature

### Parameters

Accepts the same arguments as web [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch)

<TSDoc
  definition={`
import { fetch } from "workflow";
export default fetch;`}
  showSections={['parameters']}
/>

### Returns

Returns the same response as web [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch)

<TSDoc
  definition={`
import { fetch } from "workflow";
export default fetch;`}
  showSections={['returns']}
/>

## Examples

### Basic Usage

Here's a simple example of how you can use `fetch` inside your workflow.

```typescript lineNumbers
import { fetch } from "workflow"

async function apiWorkflow() {
    "use workflow"

    // Fetch data from an API
    const response = await fetch("https://api.example.com/data") // [!code highlight]
    const data = await response.json()

    // Make a POST request
    const postResponse = await fetch("https://api.example.com/create", { // [!code highlight]
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        },
        body: JSON.stringify({ name: "test" })
    })

    return data
}
```

We call `fetch()` with a URL and optional request options, just like the standard fetch API. The workflow runtime automatically handles the response serialization.

This API is provided as a convenience to easily use `fetch` in workflow, but often, you might want to extend and implement your own fetch for more powerful error handing and retry logic.

### Customizing Fetch Behavior

Here's an example of a custom fetch wrapper that provides more sophisticated error handling with custom retry logic:

```typescript lineNumbers
import { FatalError, RetryableError } from "workflow"

export async function customFetch(
    url: string,
    init?: RequestInit
) {
    "use step"

    const response = await fetch(url, init)

    // Handle client errors (4xx) - don't retry
    if (response.status >= 400 && response.status < 500) {
        if (response.status === 429) {
            // Rate limited - retry with backoff from Retry-After header
            const retryAfter = response.headers.get("Retry-After")

            if (retryAfter) {
                // The Retry-After header is either a number (seconds) or an RFC 7231 date string
                const retryAfterValue = /^\d+$/.test(retryAfter)
                    ? parseInt(retryAfter) * 1000  // Convert seconds to milliseconds
                    : new Date(retryAfter);        // Parse RFC 7231 date format

                // Use `RetryableError` to customize the retry
                throw new RetryableError( // [!code highlight]
                    `Rate limited by ${url}`, // [!code highlight]
                    { retryAfter: retryAfterValue } // [!code highlight]
                ) // [!code highlight]
            }
        }

        // Other client errors are fatal (400, 401, 403, 404, etc.)
        throw new FatalError( // [!code highlight]
            `Client error ${response.status}: ${response.statusText}` // [!code highlight]
        ) // [!code highlight]
    }

    // Handle server errors (5xx) - will retry automatically
    if (!response.ok) {
        throw new Error(
            `Server error ${response.status}: ${response.statusText}`
        )
    }

    return response
}
```

This example demonstrates:

* Setting custom `maxRetries` to 5 retries (6 total attempts including the initial attempt).
* Throwing [`FatalError`](/docs/api-reference/workflow/fatal-error) for client errors (400-499) to prevent retries.
* Handling 429 rate limiting by reading the `Retry-After` header and using [`RetryableError`](/docs/api-reference/workflow/retryable-error).
* Allowing automatic retries for server errors (5xx).


---
title: getStepMetadata
description: Access retry attempts and timing information within step functions.
type: reference
summary: Call getStepMetadata inside a step to access retry counts, timing, and idempotency keys.
prerequisites:
  - /docs/foundations/workflows-and-steps
---

# getStepMetadata



Returns metadata available in the current step function.

You may want to use this function when you need to:

* Track retry attempts in error handling
* Access timing information of a step and execution metadata
* Generate idempotency keys for external APIs

<Callout type="warn">
  This function can only be called inside a step function.
</Callout>

```typescript lineNumbers
import { getStepMetadata } from "workflow";

async function testWorkflow() {
  "use workflow";
  await logStepId();
}

async function logStepId() {
  "use step";
  const ctx = getStepMetadata(); // [!code highlight]
  console.log(ctx.stepId); // Grab the current step ID
}
```

### Example: Use `stepId` as an idempotency key

```typescript lineNumbers
import { getStepMetadata } from "workflow";

async function chargeUser(userId: string, amount: number) {
  "use step";
  const { stepId } = getStepMetadata();

  await stripe.charges.create(
    {
      amount,
      currency: "usd",
      customer: userId,
    },
    {
      idempotencyKey: `charge:${stepId}`, // [!code highlight]
    }
  );
}
```

<Callout type="info">
  Learn more about patterns and caveats in the{" "}
  <a href="/docs/foundations/idempotency">Idempotency</a> guide.
</Callout>

## API Signature

### Parameters

<TSDoc
  definition={`
import { getStepMetadata } from "workflow";
export default getStepMetadata;`}
  showSections={["parameters"]}
/>

### Returns

<TSDoc
  definition={`
import type { StepMetadata } from "workflow";
export default StepMetadata;`}
/>


---
title: getWorkflowMetadata
description: Access run IDs and timing information within workflow functions.
type: reference
summary: Call getWorkflowMetadata inside a workflow to access the run ID and timing information.
prerequisites:
  - /docs/foundations/workflows-and-steps
---

# getWorkflowMetadata



Returns additional metadata available in the current workflow function.

You may want to use this function when you need to:

* Log workflow run IDs
* Access timing information of a workflow
* Detect whether encryption is enabled for the current run

<Callout>
  If you need to access step context, take a look at [`getStepMetadata`](/docs/api-reference/workflow/get-step-metadata).
</Callout>

```typescript lineNumbers
import { getWorkflowMetadata } from "workflow"

async function testWorkflow() {
    "use workflow"

    const ctx = getWorkflowMetadata() // [!code highlight]
    console.log(ctx.workflowRunId)
}
```

### Detecting Workflow Runtime

You can use `getWorkflowMetadata` to detect whether your code is running inside a workflow context. This is useful when building shared utilities that need to behave differently inside and outside of workflows.

Since `getWorkflowMetadata` throws when called outside a workflow, you can wrap it in a try-catch:

```typescript lineNumbers
import { getWorkflowMetadata } from "workflow"

function isInWorkflow(): boolean {
    try {
        getWorkflowMetadata()
        return true
    } catch {
        return false
    }
}
```

For example, a logging utility could include the workflow run ID when available:

```typescript lineNumbers
import { getWorkflowMetadata } from "workflow"

function log(message: string) {
    try {
        const { workflowRunId } = getWorkflowMetadata()
        console.log(`[workflow:${workflowRunId}] ${message}`)
    } catch {
        console.log(message)
    }
}
```

### Detecting Encryption

The `features` object indicates which capabilities are active for the current run. Library authors can use `features.encryption` to control whether sensitive data is included in step return values, which are serialized to the event log:

```typescript lineNumbers
import { getWorkflowMetadata } from "workflow"

declare function getUserProfile(userId: string): Promise<{ name: string; ssn: string }>; // @setup

async function fetchUserProfile(userId: string) {
    "use step"

    const { features } = getWorkflowMetadata() // [!code highlight]
    const profile = await getUserProfile(userId)

    if (!features.encryption) { // [!code highlight]
        // Omit sensitive fields from the return value,
        // since it will be stored unencrypted in the event log
        const { ssn, ...safe } = profile
        return safe
    }

    return profile
}
```

## API Signature

### Parameters

<TSDoc
  definition={`
import { getWorkflowMetadata } from "workflow";
export default getWorkflowMetadata;`}
  showSections={['parameters']}
/>

### Returns

<TSDoc
  definition={`
import type { WorkflowMetadata } from "workflow";
export default WorkflowMetadata;`}
/>


---
title: getWritable
description: Retrieves the current workflow run's default writable stream.
type: reference
summary: Use getWritable to access the workflow run's output stream for real-time data streaming.
prerequisites:
  - /docs/foundations/streaming
---

# getWritable



The writable stream can be obtained in workflow functions and passed to steps, or called directly within step functions to write data that can be read outside the workflow by using the `readable` property of the [`Run` object](/docs/api-reference/workflow-api/get-run).

Use this function in your workflows and steps to produce streaming output that can be consumed by clients in real-time.

<Callout type="warn">
  This function can only be called inside a workflow or step function (functions
  with `"use workflow"` or `"use step"` directive)
</Callout>

<Callout type="error">
  **Important:** While you can call `getWritable()` inside a workflow function
  to obtain the stream, you **cannot interact with the stream directly** in the
  workflow context (e.g., calling `getWriter()`, `write()`, or `close()`). The
  stream must be passed to step functions as arguments, or steps can call
  `getWritable()` directly themselves.
</Callout>

```typescript lineNumbers
import { getWritable } from "workflow";

export async function myWorkflow() {
  "use workflow";

  // Get the writable stream
  const writable = getWritable(); // [!code highlight]

  // Pass it to a step function to interact with it
  await writeToStream(writable); // [!code highlight]
}

async function writeToStream(writable: WritableStream) {
  "use step";

  const writer = writable.getWriter();
  await writer.write(new TextEncoder().encode("Hello from workflow!"));
  writer.releaseLock();
  await writable.close();
}
```

## API Signature

### Parameters

<TSDoc
  definition={`
import { getWritable } from "workflow";
export default getWritable;`}
  showSections={["parameters"]}
/>

### Returns

<TSDoc
  definition={`
import { getWritable } from "workflow";
export default getWritable;`}
  showSections={["returns"]}
/>

Returns a `WritableStream<W>` where `W` is the type of data you plan to write to the stream.

## Good to Know

* **Workflow functions can only obtain the stream** - Call `getWritable()` in a workflow to get the stream reference, but you cannot call methods like `getWriter()`, `write()`, or `close()` directly in the workflow context.
* **Step functions can interact with streams** - Steps can receive the stream as an argument or call `getWritable()` directly, and they can freely interact with it (write, close, etc.).
* When called from a workflow, the stream must be passed as an argument to steps for interaction.
* When called from a step, it retrieves the same workflow-scoped stream directly.
* Always release the writer lock after writing to prevent resource leaks.
* The stream can write binary data (using `TextEncoder`) or structured objects.
* Remember to close the stream when finished to signal completion.

## Examples

### Basic Text Streaming

Here's a simple example streaming text data:

```typescript lineNumbers
import { sleep, getWritable } from "workflow";

export async function outputStreamWorkflow() {
  "use workflow";

  const writable = getWritable(); // [!code highlight]

  await sleep("1s");
  await stepWithOutputStream(writable);
  await sleep("1s");
  await stepCloseOutputStream(writable);

  return "done";
}

async function stepWithOutputStream(writable: WritableStream) {
  "use step";

  const writer = writable.getWriter();
  // Write binary data using TextEncoder
  await writer.write(new TextEncoder().encode("Hello, world!"));
  writer.releaseLock();
}

async function stepCloseOutputStream(writable: WritableStream) {
  "use step";

  // Close the stream to signal completion
  await writable.close();
}
```

### Calling `getWritable()` Inside Steps

You can also call `getWritable()` directly inside step functions without passing it as a parameter:

```typescript lineNumbers
import { sleep, getWritable } from "workflow";

export async function outputStreamFromStepWorkflow() {
  "use workflow";

  // No need to create or pass the stream - steps can get it themselves
  await sleep("1s");
  await stepWithOutputStreamInside();
  await sleep("1s");
  await stepCloseOutputStreamInside();

  return "done";
}

async function stepWithOutputStreamInside() {
  "use step";

  // Call getWritable() directly inside the step // [!code highlight]
  const writable = getWritable(); // [!code highlight]
  const writer = writable.getWriter();

  await writer.write(new TextEncoder().encode("Hello from step!"));
  writer.releaseLock();
}

async function stepCloseOutputStreamInside() {
  "use step";

  // Call getWritable() to get the same stream // [!code highlight]
  const writable = getWritable(); // [!code highlight]
  await writable.close();
}
```

### Using Namespaced Streams in Steps

You can also use namespaced streams when calling `getWritable()` from steps:

```typescript lineNumbers
import { getWritable } from "workflow";

export async function multiStreamWorkflow() {
  "use workflow";

  // Steps will access both streams by namespace
  await writeToDefaultStream();
  await writeToNamedStream();
  await closeStreams();

  return "done";
}

async function writeToDefaultStream() {
  "use step";

  const writable = getWritable(); // Default stream
  const writer = writable.getWriter();
  await writer.write({ message: "Default stream data" });
  writer.releaseLock();
}

async function writeToNamedStream() {
  "use step";

  const writable = getWritable({ namespace: "logs" }); // [!code highlight]
  const writer = writable.getWriter();
  await writer.write({ log: "Named stream data" });
  writer.releaseLock();
}

async function closeStreams() {
  "use step";

  await getWritable().close(); // Close default stream
  await getWritable({ namespace: "logs" }).close(); // Close named stream
}
```

### Advanced Chat Streaming

Here's a more complex example showing how you might stream AI chat responses:

```typescript lineNumbers
import { getWritable } from "workflow";
import { generateId, streamText, type UIMessageChunk } from "ai";
import type { ModelMessage } from "ai";

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

  // Get typed writable stream for UI message chunks
  const writable = getWritable<UIMessageChunk>(); // [!code highlight]

  // Start the stream
  await startStream(writable);

  let currentMessages = [...messages];

  // Process messages in steps
  for (let i = 0; i < MAX_STEPS; i++) {
    const result = await streamTextStep(currentMessages, writable);
    currentMessages.push(...result.messages);

    if (result.finishReason !== "tool-calls") {
      break;
    }
  }

  // End the stream
  await endStream(writable);
}

async function startStream(writable: WritableStream<UIMessageChunk>) {
  "use step";

  const writer = writable.getWriter();

  // Send start message
  writer.write({
    type: "start",
    messageMetadata: {
      createdAt: Date.now(),
      messageId: generateId(),
    },
  });

  writer.releaseLock();
}

async function streamTextStep(
  messages: ModelMessage[],
  writable: WritableStream<UIMessageChunk>
) {
  "use step";

  const writer = writable.getWriter();

  // Call streamText from the AI SDK
  const result = streamText({
    model: myModel,
    messages,
  });

  // Pipe the AI stream into the writable stream
  const reader = result
    .toUIMessageStream({ sendStart: false, sendFinish: false })
    .getReader();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    await writer.write(value);
  }

  reader.releaseLock();
  writer.releaseLock();

  // Return the result for the workflow to use
  return {
    messages: await result.response.then((r) => r.messages),
    finishReason: await result.finishReason,
  };
}

async function endStream(writable: WritableStream<UIMessageChunk>) {
  "use step";

  // Close the stream to signal completion
  await writable.close();
}
```


---
title: workflow
description: Core workflow primitives for steps, streaming, webhooks, and error handling.
type: overview
summary: Explore the core workflow package for steps, streaming, hooks, and error handling.
related:
  - /docs/foundations/workflows-and-steps
  - /docs/foundations/hooks
  - /docs/foundations/streaming
  - /docs/foundations/errors-and-retries
---

# workflow



Core workflow primitives including steps, context management, streaming, webhooks, and error handling.

## Installation

<CodeBlockTabs defaultValue="npm">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="npm">
      npm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="pnpm">
      pnpm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="yarn">
      yarn
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="bun">
      bun
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="npm">
    ```bash
    npm i workflow
    ```
  </CodeBlockTab>

  <CodeBlockTab value="pnpm">
    ```bash
    pnpm add workflow
    ```
  </CodeBlockTab>

  <CodeBlockTab value="yarn">
    ```bash
    yarn add workflow
    ```
  </CodeBlockTab>

  <CodeBlockTab value="bun">
    ```bash
    bun add workflow
    ```
  </CodeBlockTab>
</CodeBlockTabs>

## Functions

Workflow SDK contains the following functions you can use inside your workflow functions:

<Cards>
  <Card href="/docs/api-reference/workflow/get-workflow-metadata" title="getWorkflowMetadata()">
    A function that returns context about the current workflow execution.
  </Card>

  <Card href="/docs/api-reference/workflow/get-step-metadata" title="getStepMetadata()">
    A function that returns context about the current step execution.
  </Card>

  <Card href="/docs/api-reference/workflow/sleep" title="sleep()">
    Sleeping workflows for a specified duration. Deterministic and replay-safe.
  </Card>

  <Card href="/docs/api-reference/workflow/fetch" title="fetch()">
    Make HTTP requests from within a workflow with automatic retry semantics.
  </Card>

  <Card href="/docs/api-reference/workflow/create-hook" title="createHook()">
    Create a low-level hook to receive arbitrary payloads from external systems.
  </Card>

  <Card href="/docs/api-reference/workflow/define-hook" title="defineHook()">
    Type-safe hook helper for consistent payload types.
  </Card>

  <Card href="/docs/api-reference/workflow/create-webhook" title="createWebhook()">
    Create a webhook that suspends the workflow until an HTTP request is received.
  </Card>

  <Card href="/docs/api-reference/workflow/get-writable" title="getWritable()">
    Access the current workflow run's default stream.
  </Card>
</Cards>

## Error Classes

Workflow SDK includes error classes that can be thrown in a workflow or step to change the error exit strategy of a workflow.

<Cards>
  <Card href="/docs/api-reference/workflow/fatal-error" title="FatalError()">
    When thrown, marks a step as failed and the step is not retried.
  </Card>

  <Card href="/docs/api-reference/workflow/retryable-error" title="RetryableError()">
    When thrown, marks a step as retryable with an optional parameter.
  </Card>
</Cards>


---
title: RetryableError
description: Throw to retry a step, optionally after a specified duration.
type: reference
summary: Throw RetryableError in a step to trigger a retry with an optional delay.
prerequisites:
  - /docs/foundations/errors-and-retries
related:
  - /docs/api-reference/workflow/fatal-error
---

# RetryableError



When a `RetryableError` is thrown in a step, it indicates that the workflow should retry a step. Additionally, it contains a parameter `retryAfter` indicating when the step should be retried after.

You should use this when you want to retry a step or retry after a certain duration.

```typescript lineNumbers
import { RetryableError } from "workflow"

async function retryableWorkflow() {
    "use workflow"
    await retryStep();
}

async function retryStep() {
    "use step"
    throw new RetryableError("Retryable!") // [!code highlight]
}
```

<Callout>
  The difference between `Error` and `RetryableError` may not be entirely obvious, since when both are thrown, they both retry. The difference is that `RetryableError` has an additional configurable `retryAfter` parameter.
</Callout>

## API Signature

### Parameters

<TSDoc
  definition={`
import { type RetryableErrorOptions } from "workflow";
interface RetryableError {
  options?: RetryableErrorOptions;
  message: string;
}

export default RetryableError;`}
/>

#### RetryableErrorOptions

<TSDoc
  definition={`
import { type RetryableErrorOptions } from "workflow";
export default RetryableErrorOptions;`}
/>

## Examples

### Retrying after a duration

`RetryableError` can be configured with a `retryAfter` parameter to specify when the step should be retried after.

```typescript lineNumbers
import { RetryableError } from "workflow"

async function retryableWorkflow() {
    "use workflow"
    await retryStep();
}

async function retryStep() {
    "use step"
    throw new RetryableError("Retryable!", {
        retryAfter: "5m" // - supports "5m", "30s", "1h", etc. // [!code highlight]
    })
}
```

You can also specify the retry delay in milliseconds:

```typescript lineNumbers
import { RetryableError } from "workflow"

async function retryableWorkflow() {
    "use workflow"
    await retryStep();
}

async function retryStep() {
    "use step"
    throw new RetryableError("Retryable!", {
        retryAfter: 5000 // - 5000 milliseconds = 5 seconds // [!code highlight]
    })
}
```

Or retry at a specific date and time:

```typescript lineNumbers
import { RetryableError } from "workflow"

async function retryableWorkflow() {
    "use workflow"
    await retryStep();
}

async function retryStep() {
    "use step"
    throw new RetryableError("Retryable!", {
        retryAfter: new Date(Date.now() + 60000) // - retry after 1 minute // [!code highlight]
    })
}
```


---
title: sleep
description: Suspend a workflow for a duration or until a date without consuming resources.
type: reference
summary: Use sleep to suspend a workflow for a duration or until a specific date without consuming resources.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/ai/sleep-and-delays
---

# sleep



Suspends a workflow for a specified duration or until an end date without consuming any resources. Once the duration or end date passes, the workflow will resume execution.

This is useful when you want to resume a workflow after some duration or date.

<Callout>
  `sleep` is a *special* type of step function and should be called directly inside workflow functions.
</Callout>

```typescript lineNumbers
import { sleep } from "workflow"

async function testWorkflow() {
    "use workflow"
    await sleep("10s") // [!code highlight]
}
```

## API Signature

### Parameters

<TSDoc
  definition={`
import { sleep } from "workflow";
export default sleep;`}
  showSections={['parameters']}
/>

## Examples

### Sleeping With a Duration

You can specify a duration for `sleep` to suspend the workflow for a fixed amount of time.

```typescript lineNumbers
import { sleep } from "workflow"

async function testWorkflow() {
    "use workflow"
    await sleep("1d") // [!code highlight]
}
```

### Sleeping Until an End Date

You can specify a future `Date` object for `sleep` to suspend the workflow until a specific date.

```typescript lineNumbers
import { sleep } from "workflow"

async function testWorkflow() {
    "use workflow"
    await sleep(new Date(Date.now() + 10_000)) // [!code highlight]
}
```


---
title: getHookByToken
description: Retrieve hook details and workflow run information by token.
type: reference
summary: Use getHookByToken to look up a hook's metadata and associated workflow run before resuming it.
prerequisites:
  - /docs/foundations/hooks
related:
  - /docs/foundations/idempotency
---

# getHookByToken



Retrieves a hook by its unique token, returning the associated workflow run information and any metadata that was set when the hook was created. This function is useful for inspecting hook details before deciding whether to resume a workflow.

<Callout type="warn">
  `getHookByToken` is a runtime function that must be called from outside a workflow function.
</Callout>

<Callout type="info">
  Looking up a deterministic hook token is useful in hook-based idempotency flows, but it is only an advisory check. If no hook exists yet, another request can still start the same workflow before your `start()` call registers its hook. Use the lookup to avoid obvious duplicate starts, and handle the race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work — on a conflict it resolves with the run that owns the token, so the duplicate can route the caller to the active owner. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
</Callout>

```typescript lineNumbers
import { getHookByToken } from "workflow/api";

export async function POST(request: Request) {
  const { token } = await request.json();
  const hook = await getHookByToken(token);
  console.log("Hook belongs to run:", hook.runId);
}
```

## API Signature

### Parameters

<TSDoc
  definition={`
import { getHookByToken } from "workflow/api";
export default getHookByToken;`}
  showSections={["parameters"]}
/>

### Returns

Returns a `Promise<Hook>` that resolves to:

<TSDoc
  definition={`
import type { Hook } from "@workflow/world";
export default Hook;`}
  showSections={["returns"]}
/>

## Examples

### Basic Hook Lookup

Retrieve hook information before resuming:

```typescript lineNumbers
import { getHookByToken, resumeHook } from "workflow/api";

export async function POST(request: Request) {
  const { token, data } = await request.json();

  try {
    // First, get the hook to inspect its metadata
    const hook = await getHookByToken(token); // [!code highlight]

    console.log("Resuming workflow run:", hook.runId);
    console.log("Hook metadata:", hook.metadata);

    // Then resume the hook with the payload
    await resumeHook(token, data);

    return Response.json({
      success: true,
      runId: hook.runId
    });
  } catch (error) {
    return new Response("Hook not found", { status: 404 });
  }
}
```

### Validating Hook Before Resume

Use `getHookByToken` to validate hook ownership or metadata before resuming:

```typescript lineNumbers
import { getHookByToken, resumeHook } from "workflow/api";

export async function POST(request: Request) {
  const { token, userId, data } = await request.json();

  try {
    const hook = await getHookByToken(token); // [!code highlight]
    const metadata = hook.metadata as { allowedUserId?: string } | undefined;

    // Validate that the hook metadata matches the user
    if (metadata?.allowedUserId !== userId) {
      return Response.json(
        { error: "Unauthorized to resume this hook" },
        { status: 403 }
      );
    }

    await resumeHook(token, data);
    return Response.json({ success: true, runId: hook.runId });
  } catch (error) {
    return Response.json({ error: "Hook not found" }, { status: 404 });
  }
}
```

### Checking Hook Environment

Verify the hook belongs to the expected environment:

```typescript lineNumbers
import { getHookByToken, resumeHook } from "workflow/api";

export async function POST(request: Request) {
  const { token, data } = await request.json();
  const expectedEnv = process.env.VERCEL_ENV || "development";

  try {
    const hook = await getHookByToken(token); // [!code highlight]

    if (hook.environment !== expectedEnv) {
      return Response.json(
        { error: `Hook belongs to ${hook.environment} environment` },
        { status: 400 }
      );
    }

    await resumeHook(token, data);
    return Response.json({ runId: hook.runId });
  } catch (error) {
    return Response.json({ error: "Hook not found" }, { status: 404 });
  }
}
```

### Logging Hook Information

Log hook details for debugging or auditing:

```typescript lineNumbers
import { getHookByToken, resumeHook } from "workflow/api";

export async function POST(request: Request) {
  const url = new URL(request.url);
  const token = url.searchParams.get("token");

  if (!token) {
    return Response.json({ error: "Missing token" }, { status: 400 });
  }

  try {
    const hook = await getHookByToken(token); // [!code highlight]

    // Log for auditing
    console.log({
      action: "hook_resume",
      runId: hook.runId,
      hookId: hook.hookId,
      projectId: hook.projectId,
      createdAt: hook.createdAt,
    });

    const body = await request.json();
    await resumeHook(token, body);

    return Response.json({ success: true });
  } catch (error) {
    return Response.json({ error: "Hook not found" }, { status: 404 });
  }
}
```

## Related Functions

* [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with a payload.
* [`createHook()`](/docs/api-reference/workflow/create-hook) - Create a hook in a workflow.
* [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper.
* [Idempotency](/docs/foundations/idempotency) - Deduplicate step side effects and workflow starts.


---
title: getRun
description: Retrieve workflow run metadata and status without waiting for completion.
type: reference
summary: Use getRun to check a workflow run's status and metadata without blocking on completion.
prerequisites:
  - /docs/foundations/starting-workflows
related:
  - /docs/foundations/idempotency
---

# getRun



Retrieves the workflow run metadata and status information for a given run ID. This function provides immediate access to workflow run details without waiting for completion, making it ideal for status checking and monitoring.

Use this function when you need to check workflow status, get timing information, or access workflow metadata without blocking on workflow completion.

<Callout type="info">
  `getRun()` retrieves a run when you already have its `runId`. It does not look up runs by a business key. For retried requests that should route to one active workflow, use a deterministic hook token and [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token). After a hook conflict, `HookConflictError.conflictingRunId` can be passed to `getRun()` to inspect, stream, or return the active owner. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
</Callout>

```typescript lineNumbers
import { getRun } from "workflow/api";

const run = getRun("my-run-id");
```

## API Signature

### Parameters

<TSDoc
  definition={`
import { getRun } from "workflow/api";
export default getRun;`}
  showSections={["parameters"]}
/>

### Returns

Returns a `Run` object:

<TSDoc
  definition={`
import { Run } from "workflow/api";
export default Run;`}
  showSections={["returns"]}
/>

#### WorkflowReadableStream

`run.getReadable()` returns a `WorkflowReadableStream` — a standard `ReadableStream` extended with a `getTailIndex()` helper:

<TSDoc
  definition={`
import type { WorkflowReadableStream } from "workflow/api";
export default WorkflowReadableStream;`}
/>

`getTailIndex()` returns the index of the last known chunk (0-based), or `-1` when no chunks have been written. This is useful when building [reconnection endpoints](/docs/ai/resumable-streams) that need to inform clients where the stream starts.

#### WorkflowReadableStreamOptions

<TSDoc
  definition={`
import type { WorkflowReadableStreamOptions } from "workflow/api";
export default WorkflowReadableStreamOptions;`}
/>

#### StopSleepOptions

<TSDoc
  definition={`
import type { StopSleepOptions } from "workflow/api";
export default StopSleepOptions;`}
/>

#### StopSleepResult

<TSDoc
  definition={`
import type { StopSleepResult } from "workflow/api";
export default StopSleepResult;`}
/>

## Examples

### Check if a Run Exists

Use the `exists` getter to check whether a workflow run exists without throwing when the run is not found:

```typescript lineNumbers
import { getRun } from "workflow/api";

export async function GET(req: Request) {
  const url = new URL(req.url);
  const runId = url.searchParams.get("runId");

  if (!runId) {
    return Response.json({ error: "No runId provided" }, { status: 400 });
  }

  const run = getRun(runId);

  if (!(await run.exists)) { // [!code highlight]
    return Response.json(
      { error: "Workflow run not found" },
      { status: 404 }
    );
  }

  const status = await run.status;
  return Response.json({ status });
}
```

### Basic Status Check

Check the current status of a workflow run:

```typescript lineNumbers
import { getRun } from "workflow/api";

export async function GET(req: Request) {
  const url = new URL(req.url);
  const runId = url.searchParams.get("runId");

  if (!runId) {
    return Response.json({ error: "No runId provided" }, { status: 400 });
  }

  try {
    const run = getRun(runId); // [!code highlight]
    const status = await run.status;

    return Response.json({ status });
  } catch (error) {
    return Response.json(
      { error: "Workflow run not found" },
      { status: 404 }
    );
  }
}
```

### Wake Up a Sleeping Workflow

Interrupt pending `sleep()` calls to resume a workflow early. This is useful for testing workflows or building custom UIs that let users skip wait periods:

```typescript lineNumbers
import { getRun } from "workflow/api";

export async function POST(req: Request) {
  const { runId } = await req.json();
  const run = getRun(runId);

  // Wake up all pending sleep calls
  const { stoppedCount } = await run.wakeUp(); // [!code highlight]

  return Response.json({ stoppedCount });
}
```

You can also target specific sleep calls by correlation ID:

```typescript lineNumbers
import { getRun } from "workflow/api";

const run = getRun("my-run-id"); // @setup
const { stoppedCount } = await run.wakeUp({
  correlationIds: ["wait_abc123"],
});
```

## Related Functions

* [`start()`](/docs/api-reference/workflow-api/start) - Start a new workflow and get its run ID.


---
title: workflow/api
description: Runtime functions to inspect runs, start workflows, and manage hooks.
type: overview
summary: Explore runtime functions for starting workflows, inspecting runs, and managing hooks.
---

# workflow/api



API reference for runtime functions from the `workflow/api` package.

## Functions

The API package is for access and introspection of workflow data to inspect runs, start new runs, and manage hooks.

<Cards>
  <Card href="/docs/api-reference/workflow-api/start" title="start()">
    Start/enqueue a new workflow run.
  </Card>

  <Card href="/docs/api-reference/workflow-api/resume-hook" title="resumeHook()">
    Resume a workflow by sending a payload to a hook.
  </Card>

  <Card href="/docs/api-reference/workflow-api/resume-webhook" title="resumeWebhook()">
    Resume a workflow by sending a `Request` to a webhook.
  </Card>

  <Card href="/docs/api-reference/workflow-api/get-hook-by-token" title="getHookByToken()">
    Get hook details and metadata by its token.
  </Card>

  <Card href="/docs/api-reference/workflow-api/get-run" title="getRun()">
    Get workflow run status and metadata without waiting for completion.
  </Card>
</Cards>

<Callout type="info">
  Looking for `getWorld()` and the World SDK? They are exported from `workflow/runtime` — see the [`workflow/runtime` reference](/docs/api-reference/workflow-runtime).
</Callout>


---
title: resumeHook
description: Resume a paused workflow by sending a payload to a hook token.
type: reference
summary: Use resumeHook to send a payload to a hook token and resume a paused workflow.
prerequisites:
  - /docs/foundations/hooks
related:
  - /docs/api-reference/workflow-api/resume-webhook
  - /docs/foundations/idempotency
---

# resumeHook



Resumes a workflow run by sending a payload to a hook identified by its token.

It creates a `hook_received` event and re-triggers the workflow to continue execution.

<Callout type="warn">
  `resumeHook` is a runtime function that must be called from outside a workflow function.
</Callout>

```typescript lineNumbers
import { resumeHook } from "workflow/api";

export async function POST(request: Request) {
  const { token, data } = await request.json();

  try {
    const result = await resumeHook(token, data); // [!code highlight]
    return Response.json({
      runId: result.runId
    });
  } catch (error) {
    return new Response("Hook not found", { status: 404 });
  }
}
```

## API Signature

### Parameters

<TSDoc
  definition={`
import { resumeHook } from "workflow/api";
export default resumeHook;`}
  showSections={["parameters"]}
/>

### Returns

Returns a `Promise<Hook>` that resolves to:

<TSDoc
  definition={`
import type { Hook } from "@workflow/world";
export default Hook;`}
  showSections={["returns"]}
/>

## Examples

### Basic API Route

Using `resumeHook` in a basic API route to resume a hook:

```typescript lineNumbers
import { resumeHook } from "workflow/api";

export async function POST(request: Request) {
  const { token, data } = await request.json();

  try {
    const result = await resumeHook(token, data); // [!code highlight]

    return Response.json({
      success: true,
      runId: result.runId
    });
  } catch (error) {
    return new Response("Hook not found", { status: 404 });
  }
}
```

### With Type Safety

Defining a payload type and using `resumeHook` to resume a hook with type safety:

```typescript lineNumbers
import { resumeHook } from "workflow/api";

type ApprovalPayload = {
  approved: boolean;
  comment: string;
};

export async function POST(request: Request) {
  const { token, approved, comment } = await request.json();

  try {
    const result = await resumeHook<ApprovalPayload>(token, { // [!code highlight]
      approved, // [!code highlight]
      comment, // [!code highlight]
    }); // [!code highlight]

    return Response.json({ runId: result.runId });
  } catch (error) {
    return Response.json({ error: "Invalid token" }, { status: 404 });
  }
}
```

### Server Action (Next.js)

Using `resumeHook` in Next.js server actions to resume a hook:

```typescript lineNumbers
"use server";

import { resumeHook } from "workflow/api";

export async function approveRequest(token: string, approved: boolean) {
  try {
    const result = await resumeHook(token, { approved });
    return result.runId;
  } catch (error) {
    throw new Error("Invalid approval token");
  }
}
```

### Webhook Handler

Using `resumeHook` in a generic webhook handler to resume a hook:

```typescript lineNumbers
import { resumeHook } from "workflow/api";

// Generic webhook handler that forwards data to a hook
export async function POST(request: Request) {
  const url = new URL(request.url);
  const token = url.searchParams.get("token");

  if (!token) {
    return Response.json({ error: "Missing token" }, { status: 400 });
  }

  try {
    const body = await request.json();
    const result = await resumeHook(token, body);

    return Response.json({ success: true, runId: result.runId });
  } catch (error) {
    return Response.json({ error: "Hook not found" }, { status: 404 });
  }
}
```

### Resume or Start

A common endpoint shape is "resume or start": one route that resumes the active workflow run for a business key if one exists, or starts a new run otherwise. This comes up when the workflow uses a deterministic hook token as its idempotency key — for example, one active run per order or conversation.

`resumeHook()` is the resume half of that flow. Try it first; if it throws `HookNotFoundError`, no active run owns the token yet, so start the workflow. One subtlety: `start()` returns before the new run executes and registers its hook, so you cannot resume immediately after starting. Retry the resume until the hook is registered — if you drop the payload and only start the workflow, the data from this request is lost.

```typescript lineNumbers
import { resumeHook, start } from "workflow/api";
import { HookNotFoundError } from "workflow/errors";
import { processOrder } from "./workflows/process-order";

type OrderRequest = { confirmed: boolean };

async function resumeWithRetry(token: string, payload: OrderRequest) {
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      return await resumeHook(token, payload); // [!code highlight]
    } catch (error) {
      if (!HookNotFoundError.is(error)) throw error;
      await new Promise((resolve) => setTimeout(resolve, 100));
    }
  }

  throw new Error("Workflow did not register its hook in time");
}

export async function POST(request: Request) {
  const { orderId, confirmed } = await request.json();
  const token = `order:${orderId}`;
  const payload = { confirmed };

  try {
    // An active run already owns this token: resume it.
    const hook = await resumeHook(token, payload); // [!code highlight]
    return Response.json({ runId: hook.runId, reused: true });
  } catch (error) {
    if (!HookNotFoundError.is(error)) throw error;
  }

  // No hook yet: start a new run, then retry the resume so this
  // request's payload still reaches the workflow.
  const run = await start(processOrder, [orderId]); // [!code highlight]
  const resumed = await resumeWithRetry(token, payload);

  // A concurrent request can win the race between `start()` and hook
  // registration; the resume always reaches the actual active owner.
  return Response.json({
    runId: resumed.runId,
    reused: resumed.runId !== run.runId,
  });
}
```

See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for the full pattern, including how the workflow claims the token with `hook.getConflict()` and how concurrent starts converge on one active owner.

## Related Functions

* [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) - Get hook details before resuming.
* [`createHook()`](/docs/api-reference/workflow/create-hook) - Create a hook in a workflow.
* [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper.
* [Idempotency](/docs/foundations/idempotency) - Deduplicate step side effects and workflow starts.


---
title: resumeWebhook
description: Resume a paused workflow by sending an HTTP request to a webhook token.
type: reference
summary: Use resumeWebhook to forward an HTTP request to a webhook token and resume a paused workflow.
prerequisites:
  - /docs/foundations/hooks
related:
  - /docs/api-reference/workflow-api/resume-hook
---

# resumeWebhook



Resumes a workflow run by sending an HTTP `Request` to a webhook identified by its token.

This function creates a `hook_received` event and re-triggers the workflow to continue execution. It's designed to be called from API routes or server actions that receive external HTTP requests.

<Callout type="warn">
  `resumeWebhook` is a runtime function that must be called from outside a workflow function.
</Callout>

```typescript lineNumbers
import { resumeWebhook } from "workflow/api";

export async function POST(request: Request) {
  const url = new URL(request.url);
  const token = url.searchParams.get("token");

  if (!token) {
    return new Response("Missing token", { status: 400 });
  }

  try {
    const response = await resumeWebhook(token, request); // [!code highlight]
    return response;
  } catch (error) {
    return new Response("Webhook not found", { status: 404 });
  }
}
```

## API Signature

### Parameters

<TSDoc
  definition={`
import { resumeWebhook } from "workflow/api";
export default resumeWebhook;`}
  showSections={['parameters']}
/>

### Returns

Returns a `Promise<Response>` that resolves to:

* `Response`: The HTTP response from the workflow's `respondWith()` call

Throws an error if the webhook token is not found or invalid.

## Usage Note

<Callout type="warn">
  In most cases, you should not need to call `resumeWebhook()` directly. When you use `createWebhook()`, the framework automatically generates a random webhook token and provides a public URL at `/.well-known/workflow/v1/webhook/:token`. External systems can send HTTP requests directly to that URL.

  For server-side hook resumption with deterministic tokens, use [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) with [`createHook()`](/docs/api-reference/workflow/create-hook) instead.
</Callout>

## Example

Forward an incoming HTTP request to a webhook by token:

```typescript lineNumbers
import { resumeWebhook } from "workflow/api";

export async function POST(request: Request) {
  const url = new URL(request.url);
  const token = url.searchParams.get("token");

  if (!token) {
    return new Response("Token required", { status: 400 });
  }

  try {
    const response = await resumeWebhook(token, request); // [!code highlight]
    return response; // Returns the workflow's custom response
  } catch (error) {
    return new Response("Webhook not found", { status: 404 });
  }
}
```

## Related Functions

* [`createWebhook()`](/docs/api-reference/workflow/create-webhook) - Create a webhook in a workflow
* [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with arbitrary payload
* [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper


---
title: start
description: Start and enqueue a new workflow run.
type: reference
summary: Use start to programmatically enqueue a new workflow run from outside a workflow function.
prerequisites:
  - /docs/foundations/starting-workflows
related:
  - /docs/foundations/idempotency
---

# start



Start/enqueue a new workflow run.

```typescript lineNumbers
import { start } from "workflow/api";
import { myWorkflow } from "./workflows/my-workflow";

const run = await start(myWorkflow); // [!code highlight]
```

## API Signature

### Parameters

<TSDoc
  definition={`
import { start } from "workflow/api";
export default start;`}
  showSections={["parameters"]}
/>

#### StartOptions

<TSDoc
  definition={`
import type { StartOptions } from "workflow/api";
export default StartOptions;`}
/>

### Returns

Returns a `Run` object:

<TSDoc
  definition={`
import { Run } from "workflow/api";
export default Run;`}
  showSections={["returns"]}
/>

Learn more about [`WorkflowReadableStreamOptions`](/docs/api-reference/workflow-api/get-run#workflowreadablestreamoptions).

## Good to Know

* The `start()` function is used in runtime/non-workflow contexts to programmatically trigger workflow executions.
* This is different from calling workflow functions directly, which is the typical pattern in Next.js applications.
* The function returns immediately after enqueuing the workflow - it doesn't wait for the workflow to complete.
* Each call to `start()` creates a new workflow run. If retried requests must route to one active workflow, have the workflow create a deterministic hook token and use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) to reuse an already-registered active hook. The lookup is not atomic with `start()`, so concurrent callers can still create extra runs before the hook is registered; handle that race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work — on a conflict it resolves with the run that owns the token, so the duplicate can return the active owner to the caller. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Idempotency](/docs/foundations/idempotency#run-idempotency).
* All arguments must be [serializable](/docs/foundations/serialization).
* When `deploymentId` is provided, the argument types and return type become `unknown` since there is no guarantee the workflow function's types will be consistent across different deployments.

<Callout type="info">
  If `start()` throws `'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.`, the passed function was not transformed as a workflow. The two most common causes are a missing `"use workflow"` directive or missing framework integration. See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function).
</Callout>

## Examples

### With Arguments

```typescript
import { start } from "workflow/api";
import { userSignupWorkflow } from "./workflows/user-signup";

const run = await start(userSignupWorkflow, ["user@example.com"]); // [!code highlight]
```

### With `StartOptions`

```typescript
import { start } from "workflow/api";
import { myWorkflow } from "./workflows/my-workflow";

const run = await start(myWorkflow, ["arg1", "arg2"], { // [!code highlight]
  deploymentId: "custom-deployment-id" // [!code highlight]
}); // [!code highlight]
```

### Using `deploymentId: "latest"`

Set `deploymentId` to `"latest"` to automatically resolve the most recent deployment for the current environment. This is useful when you want to ensure a workflow run targets the latest deployed version of your application rather than the deployment that initiated the call. For when to use this and how it fits with default run pinning, see [Versioning](/docs/foundations/versioning).

```typescript
import { start } from "workflow/api";
import { myWorkflow } from "./workflows/my-workflow";

const run = await start(myWorkflow, ["arg1", "arg2"], { // [!code highlight]
  deploymentId: "latest" // [!code highlight]
}); // [!code highlight]
```

<Callout type="info">
  The `deploymentId` option is currently a Vercel-specific feature. Other Worlds may implement this option differently to match their own deployment runtimes, and the World spec may rename it from `deploymentId` to `version` in a future SDK version. On Vercel, `"latest"` resolves to the most recent deployment matching your current environment — the same production target for production deployments, or the same git branch for preview deployments.

  In Worlds without atomic, immutable deployments (such as local development or self-hosted Postgres), there is no notion of multiple deployments to resolve between, so `deploymentId: "latest"` has no effect: the SDK logs a warning and the run targets the current deployment. This means a workflow that opts into `"latest"` on Vercel still runs unchanged in local development.
</Callout>

<Callout type="warn">
  When using `deploymentId: "latest"`, the workflow run will execute on a potentially different deployment than the one calling `start()`. Be mindful of forward and backward compatibility:

  * **Workflow identity**: The workflow ID is derived from the function name and file path. If the latest deployment has renamed the workflow function or moved it to a different directory, the workflow ID will no longer match and the run will fail to start.
  * **Input and output compatibility**: The arguments passed to `start()` are serialized by the calling deployment but deserialized by the target deployment. Similarly, the workflow's return value is serialized by the target deployment but deserialized by the caller. If the workflow's expected arguments or return type have changed (e.g. added required fields, removed fields, or changed types), the run may fail or behave unexpectedly. Ensure that input and output schemas remain backward-compatible across deployments.
</Callout>


---
title: workflow/astro
description: Astro integration for automatic workflow bundling and route registration.
type: overview
summary: Explore the Astro integration for automatic workflow bundling and runtime support.
related:
  - /docs/getting-started/astro
---

# workflow/astro



Astro integration for Workflow SDK that transforms workflow code and builds the workflow bundles.

## Functions

<Cards>
  <Card title="workflow()" href="/docs/api-reference/workflow-astro/workflow">
    Astro integration that transforms workflow code (`"use step"`/`"use workflow"` directives)
  </Card>
</Cards>


---
title: workflow
description: Configure Astro to transform workflow directives.
type: reference
summary: Add the workflow integration to your Astro config to enable workflow directive transformation.
prerequisites:
  - /docs/getting-started/astro
---

# workflow



Returns an Astro integration that transforms workflow code (`"use step"`/`"use workflow"` directives) and builds the workflow bundles.

## Usage

To enable `"use step"` and `"use workflow"` directives while developing locally or deploying to production, add `workflow()` to the `integrations` array of your Astro config.

```typescript title="astro.config.mjs" lineNumbers
// @ts-check
import { defineConfig } from "astro/config";
import { workflow } from "workflow/astro"; // [!code highlight]

// https://astro.build/config
export default defineConfig({
  integrations: [workflow()], // [!code highlight]
});
```

The integration registers the workflow Vite transform plugins during `astro:config:setup` and builds the workflow bundles — locally during config setup, or via the Vercel builder after `astro:build:done` when deploying to Vercel.

## API Signature

### Parameters

This function does not accept any parameters in workflow 4.x. (5.x adds an options object with a `sourcemap` setting.)

### Returns

Returns an `AstroIntegration` object to include in the `integrations` array of your Astro config.


---
title: EntityConflictError
description: Thrown when a storage operation conflicts with the current entity state.
type: reference
summary: Catch EntityConflictError when a world operation conflicts with entity state (e.g. duplicate events or runs).
related:
  - /docs/api-reference/workflow-errors/workflow-world-error
  - /docs/api-reference/workflow-errors/run-expired-error
---

# EntityConflictError



`EntityConflictError` is thrown by world implementations when a storage operation conflicts with the current entity state. This includes cases like creating a run that already exists or writing an event that has already been persisted.

It corresponds to HTTP 409 Conflict semantics.

<Callout>
  The Workflow runtime handles this error automatically during replay and event deduplication. You will only encounter it when interacting with world storage APIs directly.
</Callout>

```typescript lineNumbers
import { EntityConflictError } from "workflow/errors"
declare const world: { events: { create(...args: any[]): Promise<any> } }; // @setup
declare const runId: string; // @setup
declare const event: any; // @setup

try {
  await world.events.create(runId, event);
} catch (error) {
  if (EntityConflictError.is(error)) { // [!code highlight]
    // Event already exists — safe to ignore during replay
  }
}
```

## API Signature

### Properties

<TSDoc
  definition={`
interface EntityConflictError {
/** The error message. */
message: string;
}
export default EntityConflictError;`}
/>

### Static Methods

#### `EntityConflictError.is(value)`

Type-safe check for `EntityConflictError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

```typescript
import { EntityConflictError } from "workflow/errors"
declare const error: unknown; // @setup

if (EntityConflictError.is(error)) {
  // error is typed as EntityConflictError
}
```


---
title: HookConflictError
description: Thrown when creating a hook with a token that is already in use by another workflow run.
type: reference
summary: Catch HookConflictError when a hook token is already claimed by another active workflow run.
related:
  - /docs/api-reference/workflow/create-hook
  - /docs/foundations/hooks
  - /docs/errors/hook-conflict
---

# HookConflictError



`HookConflictError` is thrown when creating a hook with a token that is already in use by another active workflow run. Hook tokens must be unique across all running workflows — see the [hook-conflict](/docs/errors/hook-conflict) error guide for resolution strategies.

```typescript lineNumbers
import { HookConflictError } from "workflow/errors"
declare function startApprovalWorkflow(token: string): Promise<void>; // @setup
declare const token: string; // @setup

try {
  await startApprovalWorkflow(token);
} catch (error) {
  if (HookConflictError.is(error)) { // [!code highlight]
    console.error(
      `Token "${error.token}" already in use by run ${error.conflictingRunId}`
    );
  }
}
```

## API Signature

### Properties

<TSDoc
  definition={`
interface HookConflictError {
/** The hook token that conflicted. */
token: string;
/** The run ID of the workflow currently holding the token, when known. */
conflictingRunId?: string;
/** The error message. */
message: string;
}
export default HookConflictError;`}
/>

### Static Methods

#### `HookConflictError.is(value)`

Type-safe check for `HookConflictError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

```typescript
import { HookConflictError } from "workflow/errors"
declare const error: unknown; // @setup

if (HookConflictError.is(error)) {
  // error is typed as HookConflictError
}
```


---
title: HookNotFoundError
description: Thrown when resuming a hook that does not exist.
type: reference
summary: Catch HookNotFoundError when calling resumeHook() or resumeWebhook() with a token that doesn't match any active hook.
related:
  - /docs/api-reference/workflow/create-hook
  - /docs/api-reference/workflow/define-hook
---

# HookNotFoundError



`HookNotFoundError` is thrown when calling `resumeHook()` or `resumeWebhook()` with a token that does not match any active hook. This typically happens when:

* The hook has expired (past its TTL)
* The hook was already consumed and disposed
* The workflow has not started yet, so the hook does not exist

```typescript lineNumbers
import { HookNotFoundError } from "workflow/errors"
declare function resumeHook(token: string, payload: any): Promise<any>; // @setup
declare const token: string; // @setup
declare const payload: any; // @setup

try {
  await resumeHook(token, payload);
} catch (error) {
  if (HookNotFoundError.is(error)) { // [!code highlight]
    console.error("Hook not found:", error.token);
  }
}
```

## API Signature

### Properties

<TSDoc
  definition={`
interface HookNotFoundError {
/** The hook token that was not found. */
token: string;
/** The error message. */
message: string;
}
export default HookNotFoundError;`}
/>

### Static Methods

#### `HookNotFoundError.is(value)`

Type-safe check for `HookNotFoundError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

```typescript
import { HookNotFoundError } from "workflow/errors"
declare const error: unknown; // @setup

if (HookNotFoundError.is(error)) {
  // error is typed as HookNotFoundError
}
```

## Examples

### Resume hook or start workflow

A common pattern for idempotent workflows is to try resuming a hook, and if it doesn't exist yet, start a new workflow run with the input data.

<Callout>
  This "resume or start" pattern is not atomic — there is a small window where a race condition is possible. A better native approach is being worked on, but this pattern works well for many use cases.
</Callout>

```typescript lineNumbers
import { HookNotFoundError } from "workflow/errors"
declare function resumeHook(token: string, data: unknown): Promise<any>; // @setup
declare function startWorkflow(name: string, data: unknown): Promise<any>; // @setup

async function handleIncomingEvent(token: string, data: unknown) {
  try {
    // Try to resume an existing hook
    await resumeHook(token, data);
  } catch (error) {
    if (HookNotFoundError.is(error)) { // [!code highlight]
      // Hook doesn't exist yet — start a new workflow run
      await startWorkflow("processEvent", data); // [!code highlight]
    } else {
      throw error;
    }
  }
}
```


---
title: workflow/errors
description: Semantic error types thrown by the Workflow SDK and its storage backends.
type: overview
summary: Explore the error classes exported from workflow/errors for handling workflow failures.
related:
  - /docs/foundations/errors-and-retries
---

# workflow/errors



API reference for the error classes exported from the `workflow/errors` package.

All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow-error), so you can catch any SDK error with a single `instanceof` check, or narrow to a specific class for fine-grained handling.

## Base Classes

<Cards>
  <Card href="/docs/api-reference/workflow-errors/workflow-error" title="WorkflowError">
    Base class for all workflow error types.
  </Card>

  <Card href="/docs/api-reference/workflow-errors/workflow-world-error" title="WorkflowWorldError">
    Base error for failures from workflow storage backends.
  </Card>
</Cards>

## Registration Errors

<Cards>
  <Card href="/docs/api-reference/workflow-errors/workflow-not-registered-error" title="WorkflowNotRegisteredError">
    Thrown when a workflow function is not registered in the current deployment.
  </Card>

  <Card href="/docs/api-reference/workflow-errors/step-not-registered-error" title="StepNotRegisteredError">
    Thrown when a step function is not registered in the current deployment.
  </Card>
</Cards>

## Run Errors

<Cards>
  <Card href="/docs/api-reference/workflow-errors/workflow-run-not-found-error" title="WorkflowRunNotFoundError">
    Thrown when operating on a workflow run that does not exist.
  </Card>

  <Card href="/docs/api-reference/workflow-errors/workflow-run-failed-error" title="WorkflowRunFailedError">
    Thrown when awaiting the return value of a failed workflow run.
  </Card>

  <Card href="/docs/api-reference/workflow-errors/workflow-run-cancelled-error" title="WorkflowRunCancelledError">
    Thrown when awaiting the return value of a cancelled workflow run.
  </Card>

  <Card href="/docs/api-reference/workflow-errors/workflow-run-not-completed-error" title="WorkflowRunNotCompletedError">
    Thrown when requesting the result of a workflow run that has not completed yet.
  </Card>

  <Card href="/docs/api-reference/workflow-errors/workflow-runtime-error" title="WorkflowRuntimeError">
    Thrown when the workflow runtime encounters an execution error, such as serialization failures or timeouts.
  </Card>

  <Card href="/docs/api-reference/workflow-errors/run-expired-error" title="RunExpiredError">
    Thrown when a workflow run has expired and can no longer be operated on.
  </Card>

  <Card href="/docs/api-reference/workflow-errors/run-not-supported-error" title="RunNotSupportedError">
    Thrown when a workflow run requires a newer workflow spec version than the installed SDK supports.
  </Card>
</Cards>

## Hook Errors

<Cards>
  <Card href="/docs/api-reference/workflow-errors/hook-not-found-error" title="HookNotFoundError">
    Thrown when resuming a hook that does not exist.
  </Card>

  <Card href="/docs/api-reference/workflow-errors/hook-conflict-error" title="HookConflictError">
    Thrown when creating a hook with a token that is already in use by another workflow run.
  </Card>
</Cards>

## Backend Errors

<Cards>
  <Card href="/docs/api-reference/workflow-errors/throttle-error" title="ThrottleError">
    Thrown when a request is rate-limited by the workflow backend.
  </Card>

  <Card href="/docs/api-reference/workflow-errors/entity-conflict-error" title="EntityConflictError">
    Thrown when a storage operation conflicts with the current entity state.
  </Card>

  <Card href="/docs/api-reference/workflow-errors/too-early-error" title="TooEarlyError">
    Thrown when a request is made before the system is ready to process it.
  </Card>
</Cards>


---
title: RunExpiredError
description: Thrown when a workflow run has expired and can no longer be operated on.
type: reference
summary: Catch RunExpiredError when a workflow run has expired and can no longer accept operations.
related:
  - /docs/api-reference/workflow-errors/workflow-world-error
  - /docs/api-reference/workflow-errors/entity-conflict-error
---

# RunExpiredError



`RunExpiredError` is thrown by world implementations when a workflow run has expired and can no longer be operated on. It corresponds to HTTP 410 Gone semantics.

<Callout>
  The Workflow runtime handles this error automatically. You will only encounter it when interacting with world storage APIs directly.
</Callout>

```typescript lineNumbers
import { RunExpiredError } from "workflow/errors"
declare const world: { events: { create(...args: any[]): Promise<any> } }; // @setup
declare const runId: string; // @setup
declare const event: any; // @setup

try {
  await world.events.create(runId, event);
} catch (error) {
  if (RunExpiredError.is(error)) { // [!code highlight]
    console.log("Run has expired and can no longer accept events");
  }
}
```

## API Signature

### Properties

<TSDoc
  definition={`
interface RunExpiredError {
/** The error message. */
message: string;
}
export default RunExpiredError;`}
/>

### Static Methods

#### `RunExpiredError.is(value)`

Type-safe check for `RunExpiredError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

```typescript
import { RunExpiredError } from "workflow/errors"
declare const error: unknown; // @setup

if (RunExpiredError.is(error)) {
  // error is typed as RunExpiredError
}
```


---
title: RunNotSupportedError
description: Thrown when a workflow run requires a newer workflow spec version than the installed SDK supports.
type: reference
summary: Catch RunNotSupportedError when stored run data requires a newer workflow package version.
related:
  - /docs/foundations/versioning
---

# RunNotSupportedError



`RunNotSupportedError` is thrown when reading a workflow run whose data was written with a newer workflow spec version than the running SDK supports. This typically means the run was created by a newer version of the `workflow` package — upgrade the package to process it.

```typescript lineNumbers
import { RunNotSupportedError } from "workflow/errors"
declare function readRun(runId: string): Promise<unknown>; // @setup
declare const runId: string; // @setup

try {
  await readRun(runId);
} catch (error) {
  if (RunNotSupportedError.is(error)) { // [!code highlight]
    console.error(
      `Run requires spec v${error.runSpecVersion}, world supports v${error.worldSpecVersion}`
    );
  }
}
```

## API Signature

### Properties

<TSDoc
  definition={`
interface RunNotSupportedError {
/** The spec version the run's stored data requires. */
runSpecVersion: number;
/** The spec version the current World supports. */
worldSpecVersion: number;
/** The error message. */
message: string;
}
export default RunNotSupportedError;`}
/>

### Static Methods

#### `RunNotSupportedError.is(value)`

Type-safe check for `RunNotSupportedError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

```typescript
import { RunNotSupportedError } from "workflow/errors"
declare const error: unknown; // @setup

if (RunNotSupportedError.is(error)) {
  // error is typed as RunNotSupportedError
}
```


---
title: StepNotRegisteredError
description: Thrown when a step function is not registered in the current deployment.
type: reference
summary: Catch StepNotRegisteredError when a step function cannot be found during execution.
related:
  - /docs/errors/step-not-registered
  - /docs/api-reference/workflow-errors/workflow-not-registered-error
---

# StepNotRegisteredError



`StepNotRegisteredError` is thrown when the runtime tries to execute a step function that is not registered in the current deployment. This is an infrastructure error — not a user code error. It typically indicates a build or bundling issue that caused the step to not be included in the deployment.

When this error occurs, the step fails (like a `FatalError`) and control is passed back to the workflow function, which can handle the failure gracefully.

```typescript lineNumbers
import { StepNotRegisteredError } from "workflow/errors"
declare const error: unknown; // @setup

if (StepNotRegisteredError.is(error)) { // [!code highlight]
  console.error("Step not registered:", error.stepName);
}
```

## API Signature

### Properties

<TSDoc
  definition={`
interface StepNotRegisteredError {
/** The name of the step function that was not found. */
stepName: string;
/** The error message. */
message: string;
}
export default StepNotRegisteredError;`}
/>

### Static Methods

#### `StepNotRegisteredError.is(value)`

Type-safe check for `StepNotRegisteredError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

<Callout>
  The `.is()` method works in server-side Node.js code (API routes, middleware, hooks). Inside `"use workflow"` functions, step errors arrive deserialized from the event log and won't be actual `StepNotRegisteredError` instances — use `error.message` matching instead. See the [troubleshooting page](/docs/errors/step-not-registered) for workflow-side error handling examples.
</Callout>

```typescript
import { StepNotRegisteredError } from "workflow/errors"
declare const error: unknown; // @setup

if (StepNotRegisteredError.is(error)) {
  // error is typed as StepNotRegisteredError
}
```


---
title: ThrottleError
description: Thrown when a request is rate-limited by the workflow backend.
type: reference
summary: Catch ThrottleError when a workflow storage operation is rate-limited (HTTP 429).
related:
  - /docs/api-reference/workflow-errors/workflow-world-error
  - /docs/api-reference/workflow-errors/too-early-error
---

# ThrottleError



`ThrottleError` is thrown when a request to the workflow backend is rate-limited. It corresponds to HTTP 429 Too Many Requests semantics.

The `retryAfter` property contains the number of seconds to wait before retrying.

<Callout>
  The Workflow runtime handles this error automatically by backing off and retrying. You will only encounter it when interacting with world storage APIs directly.
</Callout>

```typescript lineNumbers
import { ThrottleError } from "workflow/errors"
declare const world: { events: { create(...args: any[]): Promise<any> } }; // @setup
declare const runId: string; // @setup
declare const event: any; // @setup

try {
  await world.events.create(runId, event);
} catch (error) {
  if (ThrottleError.is(error)) { // [!code highlight]
    console.log(`Rate limited. Retry after ${error.retryAfter} seconds`);
  }
}
```

## API Signature

### Properties

<TSDoc
  definition={`
interface ThrottleError {
/** The number of seconds to wait before retrying. Present when the server sends a Retry-After header. */
retryAfter?: number;
/** The error message. */
message: string;
}
export default ThrottleError;`}
/>

### Static Methods

#### `ThrottleError.is(value)`

Type-safe check for `ThrottleError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

```typescript
import { ThrottleError } from "workflow/errors"
declare const error: unknown; // @setup

if (ThrottleError.is(error)) {
  // error is typed as ThrottleError
}
```


---
title: TooEarlyError
description: Thrown when a request is made before the system is ready to process it.
type: reference
summary: Catch TooEarlyError when a world operation is attempted before the system is ready.
related:
  - /docs/api-reference/workflow-errors/workflow-world-error
  - /docs/api-reference/workflow-errors/throttle-error
---

# TooEarlyError



`TooEarlyError` is thrown by world implementations when a request is made before the system is ready to process it. It corresponds to HTTP 425 Too Early semantics.

The `retryAfter` property contains the number of seconds to wait before retrying.

<Callout>
  The Workflow runtime handles this error automatically by retrying after the specified delay. You will only encounter it when interacting with world storage APIs directly.
</Callout>

```typescript lineNumbers
import { TooEarlyError } from "workflow/errors"
declare const world: { events: { create(...args: any[]): Promise<any> } }; // @setup
declare const runId: string; // @setup
declare const event: any; // @setup

try {
  await world.events.create(runId, event);
} catch (error) {
  if (TooEarlyError.is(error)) { // [!code highlight]
    console.log(`Retry after ${error.retryAfter} seconds`);
  }
}
```

## API Signature

### Properties

<TSDoc
  definition={`
interface TooEarlyError {
/** Delay in seconds before the operation can be retried. Present when the server sends a Retry-After header. */
retryAfter?: number;
/** The error message. */
message: string;
}
export default TooEarlyError;`}
/>

### Static Methods

#### `TooEarlyError.is(value)`

Type-safe check for `TooEarlyError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

```typescript
import { TooEarlyError } from "workflow/errors"
declare const error: unknown; // @setup

if (TooEarlyError.is(error)) {
  // error is typed as TooEarlyError
}
```


---
title: WorkflowError
description: Base class for all workflow error types.
type: reference
summary: All errors thrown by the Workflow SDK extend WorkflowError.
related:
  - /docs/foundations/errors-and-retries
---

# WorkflowError



`WorkflowError` is the base class that all Workflow SDK error types extend, such as [`WorkflowRunFailedError`](/docs/api-reference/workflow-errors/workflow-run-failed-error) and [`HookNotFoundError`](/docs/api-reference/workflow-errors/hook-not-found-error). It extends `Error` with an optional `cause` and, for some subclasses, a link to the relevant error documentation appended to the message.

```typescript lineNumbers
import { WorkflowError } from "workflow/errors"

const error = new WorkflowError("something went wrong", {
  cause: new Error("underlying cause"),
});
```

## API Signature

### Properties

<TSDoc
  definition={`
interface WorkflowError {
/** The error message. */
message: string;
/** The underlying cause, when provided. */
cause?: unknown;
}
export default WorkflowError;`}
/>

### Static Methods

#### `WorkflowError.is(value)`

Type-safe check for `WorkflowError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

<Callout type="warn">
  `WorkflowError.is()` matches only direct `WorkflowError` instances — not subclasses, which override the error name it checks. To handle a specific error type, use that subclass's own `.is()` method (e.g. `WorkflowRunFailedError.is(error)`).
</Callout>

```typescript
import { WorkflowError } from "workflow/errors"
declare const error: unknown; // @setup

if (WorkflowError.is(error)) {
  // error is typed as WorkflowError
}
```


---
title: WorkflowNotRegisteredError
description: Thrown when a workflow function is not registered in the current deployment.
type: reference
summary: Catch WorkflowNotRegisteredError when a workflow function cannot be found during execution.
related:
  - /docs/errors/workflow-not-registered
  - /docs/api-reference/workflow-errors/step-not-registered-error
---

# WorkflowNotRegisteredError



`WorkflowNotRegisteredError` is thrown when the runtime tries to execute a workflow function that is not registered in the current deployment. This is an infrastructure error — not a user code error. It typically means a run was started against a deployment that does not have this workflow (e.g., the workflow was renamed or moved), or there was a build/bundling issue.

When this error occurs, the run fails with a `RUNTIME_ERROR` error code.

```typescript lineNumbers
import { WorkflowNotRegisteredError } from "workflow/errors"
declare const error: unknown; // @setup

if (WorkflowNotRegisteredError.is(error)) { // [!code highlight]
  console.error("Workflow not registered:", error.workflowName);
}
```

## API Signature

### Properties

<TSDoc
  definition={`
interface WorkflowNotRegisteredError {
/** The name of the workflow function that was not found. */
workflowName: string;
/** The error message. */
message: string;
}
export default WorkflowNotRegisteredError;`}
/>

### Static Methods

#### `WorkflowNotRegisteredError.is(value)`

Type-safe check for `WorkflowNotRegisteredError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

<Callout>
  The `.is()` method works in server-side Node.js code (API routes, middleware). When checking the error from `run.returnValue`, use `WorkflowRunFailedError.is()` and inspect `error.cause` — the underlying error is deserialized from the event log.
</Callout>

```typescript
import { WorkflowNotRegisteredError } from "workflow/errors"
declare const error: unknown; // @setup

if (WorkflowNotRegisteredError.is(error)) {
  // error is typed as WorkflowNotRegisteredError
}
```


---
title: WorkflowRunCancelledError
description: Thrown when awaiting the return value of a cancelled workflow run.
type: reference
summary: Catch WorkflowRunCancelledError when awaiting run.returnValue on a run that was cancelled.
related:
  - /docs/api-reference/workflow-errors/workflow-run-failed-error
  - /docs/api-reference/workflow-errors/workflow-run-not-found-error
---

# WorkflowRunCancelledError



`WorkflowRunCancelledError` is thrown when awaiting `run.returnValue` on a workflow run that was explicitly cancelled via `run.cancel()`. Cancelled runs do not produce a return value.

You can check for cancellation before awaiting by inspecting `run.status`.

```typescript lineNumbers
import { WorkflowRunCancelledError } from "workflow/errors"
declare const run: { status: Promise<string>; returnValue: Promise<any> }; // @setup

try {
  const result = await run.returnValue;
} catch (error) {
  if (WorkflowRunCancelledError.is(error)) { // [!code highlight]
    console.log(`Run ${error.runId} was cancelled`);
  }
}
```

## API Signature

### Properties

<TSDoc
  definition={`
interface WorkflowRunCancelledError {
/** The ID of the cancelled run. */
runId: string;
/** The error message. */
message: string;
}
export default WorkflowRunCancelledError;`}
/>

### Static Methods

#### `WorkflowRunCancelledError.is(value)`

Type-safe check for `WorkflowRunCancelledError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

```typescript
import { WorkflowRunCancelledError } from "workflow/errors"
declare const error: unknown; // @setup

if (WorkflowRunCancelledError.is(error)) {
  // error is typed as WorkflowRunCancelledError
}
```


---
title: WorkflowRunFailedError
description: Thrown when awaiting the return value of a failed workflow run.
type: reference
summary: Catch WorkflowRunFailedError when awaiting run.returnValue on a run that encountered a fatal error.
related:
  - /docs/api-reference/workflow/fatal-error
  - /docs/api-reference/workflow-errors/workflow-run-cancelled-error
  - /docs/api-reference/workflow-errors/workflow-run-not-found-error
---

# WorkflowRunFailedError



`WorkflowRunFailedError` is thrown when awaiting `run.returnValue` on a workflow run whose status is `'failed'`. This indicates that the workflow encountered a fatal error during execution and cannot produce a return value.

The `cause` property holds the original thrown value, hydrated through the workflow serialization pipeline so its type identity (e.g. `FatalError`, `RetryableError`, custom `Error` subclasses), `cause` chain, and custom properties are preserved. Because any JavaScript value can be thrown, `cause` is typed as `unknown` — narrow it with `instanceof Error` (or a more specific check) before accessing fields like `message`. The high-level error classification is exposed as the top-level `errorCode` property.

```typescript lineNumbers
import { WorkflowRunFailedError } from "workflow/errors"
declare const run: { status: Promise<string>; returnValue: Promise<any> }; // @setup

try {
  const result = await run.returnValue;
} catch (error) {
  if (WorkflowRunFailedError.is(error)) { // [!code highlight]
    if (error.cause instanceof Error) {
      console.error(`Run ${error.runId} failed:`, error.cause.message);
    }
    if (error.errorCode) {
      console.error("Error code:", error.errorCode);
    }
  }
}
```

## API Signature

### Properties

<TSDoc
  definition={`
interface WorkflowRunFailedError {
/** The ID of the failed run. */
runId: string;
/**
 * The original thrown value from the failed workflow run, hydrated through
 * the workflow serialization pipeline. Preserves the original type identity
 * (Error subclasses, FatalError, custom classes with WORKFLOW_SERIALIZE,
 * etc.) and custom properties. Typed as \`unknown\` because any value can
 * be thrown — narrow with \`instanceof Error\` before accessing fields.
 */
cause: unknown;
/** The high-level error category (e.g. \`USER_ERROR\`, \`RUNTIME_ERROR\`). */
errorCode?: string;
/** The error message. */
message: string;
}
export default WorkflowRunFailedError;`}
/>

### Static Methods

#### `WorkflowRunFailedError.is(value)`

Type-safe check for `WorkflowRunFailedError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

```typescript
import { WorkflowRunFailedError } from "workflow/errors"
declare const error: unknown; // @setup

if (WorkflowRunFailedError.is(error)) {
  // error is typed as WorkflowRunFailedError
}
```


---
title: WorkflowRunNotCompletedError
description: Thrown when requesting the result of a workflow run that has not completed yet.
type: reference
summary: Catch WorkflowRunNotCompletedError when reading the return value of a run that is still pending or running.
related:
  - /docs/api-reference/workflow-api/get-run
---

# WorkflowRunNotCompletedError



`WorkflowRunNotCompletedError` is thrown when requesting the result of a workflow run that has not completed yet. The run's current status (for example `pending` or `running`) is available on the error.

[`run.returnValue()`](/docs/api-reference/workflow-api/get-run) handles this error internally — it polls until the run completes — so you will mainly encounter it when building custom polling logic on lower-level APIs.

```typescript lineNumbers
import { WorkflowRunNotCompletedError } from "workflow/errors"
declare function readRunResult(runId: string): Promise<unknown>; // @setup
declare const runId: string; // @setup

try {
  const result = await readRunResult(runId);
} catch (error) {
  if (WorkflowRunNotCompletedError.is(error)) { // [!code highlight]
    console.log(`Run ${error.runId} is still ${error.status}`);
  }
}
```

## API Signature

### Properties

<TSDoc
  definition={`
interface WorkflowRunNotCompletedError {
/** The workflow run ID. */
runId: string;
/** The run's status at the time of the error (e.g. "pending", "running"). */
status: string;
/** The error message. */
message: string;
}
export default WorkflowRunNotCompletedError;`}
/>

### Static Methods

#### `WorkflowRunNotCompletedError.is(value)`

Type-safe check for `WorkflowRunNotCompletedError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

```typescript
import { WorkflowRunNotCompletedError } from "workflow/errors"
declare const error: unknown; // @setup

if (WorkflowRunNotCompletedError.is(error)) {
  // error is typed as WorkflowRunNotCompletedError
}
```


---
title: WorkflowRunNotFoundError
description: Thrown when operating on a workflow run that does not exist.
type: reference
summary: Catch WorkflowRunNotFoundError when performing operations on a non-existent workflow run.
related:
  - /docs/api-reference/workflow-errors/workflow-run-failed-error
  - /docs/api-reference/workflow-errors/workflow-run-cancelled-error
---

# WorkflowRunNotFoundError



`WorkflowRunNotFoundError` is thrown when performing operations on a workflow run that does not exist. This includes calling methods like `run.status`, `run.cancel()`, or awaiting `run.returnValue` on a run whose ID does not match any known workflow run.

Note that `getRun(id)` itself is synchronous and will not throw — the error is raised when subsequent operations on the run object discover the run is missing.

```typescript lineNumbers
import { WorkflowRunNotFoundError } from "workflow/errors"
declare const run: { status: Promise<string>; returnValue: Promise<any> }; // @setup

try {
  const status = await run.status;
} catch (error) {
  if (WorkflowRunNotFoundError.is(error)) { // [!code highlight]
    console.error(`Run ${error.runId} does not exist`);
  }
}
```

## API Signature

### Properties

<TSDoc
  definition={`
interface WorkflowRunNotFoundError {
/** The ID of the run that was not found. */
runId: string;
/** The error message. */
message: string;
}
export default WorkflowRunNotFoundError;`}
/>

### Static Methods

#### `WorkflowRunNotFoundError.is(value)`

Type-safe check for `WorkflowRunNotFoundError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

```typescript
import { WorkflowRunNotFoundError } from "workflow/errors"
declare const error: unknown; // @setup

if (WorkflowRunNotFoundError.is(error)) {
  // error is typed as WorkflowRunNotFoundError
}
```


---
title: WorkflowRuntimeError
description: Thrown when the workflow runtime encounters an execution error, such as serialization failures or timeouts.
type: reference
summary: Catch WorkflowRuntimeError for runtime-level failures like unserializable values or workflow timeouts.
related:
  - /docs/foundations/serialization
  - /docs/foundations/errors-and-retries
---

# WorkflowRuntimeError



`WorkflowRuntimeError` is thrown when the workflow runtime encounters an error executing a workflow. Common causes include:

* Values crossing the workflow/step boundary that cannot be serialized
* Workflow execution timeouts
* Invalid runtime state, such as misconfigured streams

```typescript lineNumbers
import { WorkflowRuntimeError } from "workflow/errors"
declare function runWorkflowOperation(): Promise<void>; // @setup

try {
  await runWorkflowOperation();
} catch (error) {
  if (WorkflowRuntimeError.is(error)) { // [!code highlight]
    console.error("Workflow runtime error:", error.message);
  }
}
```

## API Signature

### Properties

<TSDoc
  definition={`
interface WorkflowRuntimeError {
/** The error message. */
message: string;
/** The underlying cause, when provided. */
cause?: unknown;
}
export default WorkflowRuntimeError;`}
/>

### Static Methods

#### `WorkflowRuntimeError.is(value)`

Type-safe check for `WorkflowRuntimeError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.

```typescript
import { WorkflowRuntimeError } from "workflow/errors"
declare const error: unknown; // @setup

if (WorkflowRuntimeError.is(error)) {
  // error is typed as WorkflowRuntimeError
}
```


---
title: WorkflowWorldError
description: Base error for failures from workflow storage backends.
type: reference
summary: Catch WorkflowWorldError to handle any error originating from a workflow world (storage backend).
related:
  - /docs/api-reference/workflow-errors/entity-conflict-error
  - /docs/api-reference/workflow-errors/run-expired-error
  - /docs/api-reference/workflow-errors/too-early-error
  - /docs/api-reference/workflow-errors/throttle-error
---

# WorkflowWorldError



`WorkflowWorldError` is the base error class for failures originating from a workflow world (storage backend). World implementations (local, Postgres, Vercel) throw subclasses of this error when storage operations fail.

You can use `instanceof WorkflowWorldError` to catch any world-related error regardless of the specific type. Note that the static `.is()` method only matches errors constructed directly as `WorkflowWorldError` — use the subclass-specific `.is()` methods (e.g. `EntityConflictError.is()`) to match specific error types.

<Callout>
  Most world errors are handled automatically by the Workflow runtime. You will typically only encounter these errors when interacting with world storage APIs directly or when there are infrastructure-level issues.
</Callout>

```typescript lineNumbers
import { WorkflowWorldError } from "workflow/errors"
declare const world: { events: { create(...args: any[]): Promise<any> } }; // @setup
declare const runId: string; // @setup
declare const event: any; // @setup

try {
  await world.events.create(runId, event);
} catch (error) {
  if (error instanceof WorkflowWorldError) { // [!code highlight]
    console.error("Storage backend error:", error.message);
  }
}
```

## API Signature

### Properties

<TSDoc
  definition={`
interface WorkflowWorldError {
/** HTTP status code from the world backend, if available. */
status?: number;
/** Machine-readable error code, if available. */
code?: string;
/** The URL that was requested, if available. */
url?: string;
/** Retry-After value in seconds, present on 429 and 425 responses. */
retryAfter?: number;
/** The error message. */
message: string;
}
export default WorkflowWorldError;`}
/>

### Static Methods

#### `WorkflowWorldError.is(value)`

Type-safe check that matches only errors constructed directly as `WorkflowWorldError`. Does not match subclasses like `EntityConflictError` — use `instanceof` to catch all world errors, or the subclass-specific `.is()` methods.

```typescript
import { WorkflowWorldError } from "workflow/errors"
declare const error: unknown; // @setup

if (WorkflowWorldError.is(error)) {
  // error is typed as WorkflowWorldError (not subclasses)
}
```

### Subclasses

The following error types extend `WorkflowWorldError`:

* [`EntityConflictError`](/docs/api-reference/workflow-errors/entity-conflict-error) — operation conflicts with entity state
* [`RunExpiredError`](/docs/api-reference/workflow-errors/run-expired-error) — run has expired
* [`TooEarlyError`](/docs/api-reference/workflow-errors/too-early-error) — request made before system is ready
* [`ThrottleError`](/docs/api-reference/workflow-errors/throttle-error) — request was rate-limited


---
title: configureWorkflowController
description: Point WorkflowController at the generated workflow bundles.
type: reference
summary: Configure the directory WorkflowController loads workflow bundles from.
prerequisites:
  - /docs/getting-started/nestjs
---

# configureWorkflowController



Configures the output directory that [`WorkflowController`](/docs/api-reference/workflow-nest/workflow-controller) loads the generated workflow bundles (`steps.mjs`, `workflows.mjs`, `webhook.mjs`, `manifest.json`) from.

[`WorkflowModule.forRoot()`](/docs/api-reference/workflow-nest/workflow-module) calls this for you with its resolved `outDir` — call it yourself only when registering `WorkflowController` manually. The controller's route handlers throw if no directory has been configured.

## Usage

```typescript title="src/app.module.ts" lineNumbers
import { join } from "node:path";
import { configureWorkflowController } from "workflow/nest"; // [!code highlight]

configureWorkflowController(join(process.cwd(), ".nestjs/workflow")); // [!code highlight]
```

## API Signature

### Parameters

| Parameter | Type     | Description                                                                                                                                                |
| --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `outDir`  | `string` | Directory containing the generated workflow bundles. Should match the `outDir` used by the builder (default: `.nestjs/workflow` in the working directory). |

### Returns

Returns `void`.


---
title: workflow/nest
description: NestJS integration for workflow bundling and HTTP routing.
type: overview
summary: Explore the NestJS integration for workflow bundle building and runtime routing.
related:
  - /docs/getting-started/nestjs
---

# workflow/nest



NestJS integration for Workflow SDK. The `WorkflowModule` builds the workflow bundles on application startup and registers the controller that serves the workflow runtime routes.

<Callout>
  NestJS integration is experimental and not yet supported for deployment to Vercel. The same exports are also available from the `@workflow/nest` package.
</Callout>

## Exports

<Cards>
  <Card title="WorkflowModule" href="/docs/api-reference/workflow-nest/workflow-module">
    NestJS module that builds workflow bundles on startup and registers the workflow controller
  </Card>

  <Card title="NestLocalBuilder" href="/docs/api-reference/workflow-nest/nest-local-builder">
    Builder that compiles workflow files into step, workflow, and webhook bundles
  </Card>

  <Card title="WorkflowController" href="/docs/api-reference/workflow-nest/workflow-controller">
    Controller that serves the workflow runtime routes under `.well-known/workflow/v1`
  </Card>

  <Card title="configureWorkflowController()" href="/docs/api-reference/workflow-nest/configure-workflow-controller">
    Points `WorkflowController` at the directory containing the generated workflow bundles
  </Card>
</Cards>


---
title: NestLocalBuilder
description: Builder that compiles workflow files into bundles for NestJS apps.
type: reference
summary: Use NestLocalBuilder to build workflow bundles programmatically in a NestJS project.
prerequisites:
  - /docs/getting-started/nestjs
---

# NestLocalBuilder



Builder that scans a NestJS project for workflow files and compiles them into the step, workflow, and webhook bundles plus a manifest. [`WorkflowModule.forRoot()`](/docs/api-reference/workflow-nest/workflow-module) creates and runs one automatically on startup — instantiate it yourself only when you need to build bundles outside the module lifecycle (e.g. a custom build script for production with `skipBuild`).

## Usage

```typescript title="scripts/build-workflows.ts" lineNumbers
import { NestLocalBuilder } from "workflow/nest"; // [!code highlight]

const builder = new NestLocalBuilder({
  dirs: ["src"],
});

await builder.build(); // [!code highlight]

console.log(`Workflow bundles written to ${builder.outDir}`);
```

## API Signature

### Constructor

`new NestLocalBuilder(options?)` creates a builder for the given options.

### Parameters

| Parameter | Type                 | Description                              |
| --------- | -------------------- | ---------------------------------------- |
| `options` | `NestBuilderOptions` | Optional. Configures the workflow build. |

#### NestBuilderOptions

| Option       | Type                  | Default                                         | Description                                                                                                                                                                                                           |
| ------------ | --------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workingDir` | `string`              | `process.cwd()`                                 | Working directory for the NestJS application.                                                                                                                                                                         |
| `dirs`       | `string[]`            | `['src']`                                       | Directories to scan for workflow files.                                                                                                                                                                               |
| `outDir`     | `string`              | `'.nestjs/workflow'` (relative to `workingDir`) | Output directory for generated workflow bundles.                                                                                                                                                                      |
| `watch`      | `boolean`             | `false`                                         | Enable watch mode for development.                                                                                                                                                                                    |
| `moduleType` | `'es6' \| 'commonjs'` | `'es6'`                                         | SWC module compilation type. When `'commonjs'`, the builder rewrites externalized imports in the steps bundle to use `require()` via `createRequire`, avoiding ESM/CJS named-export interop issues with SWC's output. |
| `distDir`    | `string`              | `'dist'`                                        | Directory where NestJS compiles `.ts` source files to `.js` (relative to `workingDir`). Used when `moduleType` is `'commonjs'` to resolve compiled file paths. Should match the `outDir` in your `tsconfig.json`.     |

### Methods

#### `build()`

Builds the workflow bundles. Writes `steps.mjs`, `workflows.mjs`, `webhook.mjs`, and `manifest.json` to the output directory (plus a `.gitignore` covering the generated files when not deploying to Vercel). Returns `Promise<void>`.

### Properties

#### `outDir`

Read-only getter that returns the output directory for generated workflow bundles — the `outDir` option as passed, or the default `.nestjs/workflow` resolved against `workingDir`.

### Returns

The constructor returns a `NestLocalBuilder` instance.


---
title: WorkflowController
description: NestJS controller that serves the workflow runtime routes.
type: reference
summary: WorkflowController handles the well-known workflow endpoints in a NestJS app.
prerequisites:
  - /docs/getting-started/nestjs
---

# WorkflowController



NestJS controller that handles the well-known workflow endpoints under `.well-known/workflow/v1`. It dynamically imports the generated workflow bundles and converts between Express/Fastify requests and the Web API `Request`/`Response` objects the workflow runtime expects. Both the Express and Fastify HTTP adapters are supported.

[`WorkflowModule.forRoot()`](/docs/api-reference/workflow-nest/workflow-module) registers this controller automatically — you only register it yourself if you are not using `WorkflowModule`.

## Usage

When registering the controller manually, call [`configureWorkflowController`](/docs/api-reference/workflow-nest/configure-workflow-controller) first so it can locate the generated bundles; its route handlers throw otherwise.

```typescript title="src/app.module.ts" lineNumbers
import { join } from "node:path";
import { Module } from "@nestjs/common";
import {
  configureWorkflowController, // [!code highlight]
  WorkflowController, // [!code highlight]
} from "workflow/nest";

configureWorkflowController(join(process.cwd(), ".nestjs/workflow")); // [!code highlight]

@Module({
  controllers: [WorkflowController], // [!code highlight]
})
export class AppModule {}
```

## Routes

| Route                                     | Method | Description                                                                                                                                 |
| ----------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `/.well-known/workflow/v1/flow`           | `POST` | Executes workflow and step work items via the combined handler in `workflows.mjs` (step registrations are imported from `steps.mjs` first). |
| `/.well-known/workflow/v1/webhook/:token` | Any    | Forwards webhook requests to the handler in `webhook.mjs`.                                                                                  |
| `/.well-known/workflow/v1/manifest.json`  | `GET`  | Serves the workflow manifest. Responds with `404` unless the `WORKFLOW_PUBLIC_MANIFEST=1` environment variable is set.                      |


---
title: WorkflowModule
description: NestJS module that builds workflow bundles and registers the workflow controller.
type: reference
summary: Import WorkflowModule.forRoot() in your AppModule to enable workflows in a NestJS app.
prerequisites:
  - /docs/getting-started/nestjs
---

# WorkflowModule



NestJS module that provides workflow functionality. It builds the workflow bundles on module initialization (`onModuleInit`) and registers the [`WorkflowController`](/docs/api-reference/workflow-nest/workflow-controller) that serves the workflow runtime routes.

## Usage

Add `WorkflowModule.forRoot()` to the `imports` array of your root module.

```typescript title="src/app.module.ts" lineNumbers
import { Module } from "@nestjs/common";
import { WorkflowModule } from "workflow/nest"; // [!code highlight]

@Module({
  imports: [WorkflowModule.forRoot()], // [!code highlight]
})
export class AppModule {}
```

If your NestJS project compiles to CommonJS via SWC, pass `moduleType` and `distDir` so the builder can rewrite imports in the generated bundles:

```typescript title="src/app.module.ts" lineNumbers
import { Module } from "@nestjs/common";
import { WorkflowModule } from "workflow/nest";

@Module({
  imports: [
    WorkflowModule.forRoot({
      moduleType: "commonjs", // [!code highlight]
      distDir: "dist", // [!code highlight]
    }),
  ],
})
export class AppModule {}
```

## API Signature

### Static Methods

#### `forRoot(options?)`

Configures the module and returns a NestJS `DynamicModule` registered as `global`. It calls [`configureWorkflowController`](/docs/api-reference/workflow-nest/configure-workflow-controller) with the resolved output directory, and (unless `skipBuild` is set) creates a [`NestLocalBuilder`](/docs/api-reference/workflow-nest/nest-local-builder) that builds the workflow bundles when the module initializes.

### Parameters

| Parameter | Type                    | Description                              |
| --------- | ----------------------- | ---------------------------------------- |
| `options` | `WorkflowModuleOptions` | Optional. Configures the workflow build. |

#### WorkflowModuleOptions

Extends [`NestBuilderOptions`](/docs/api-reference/workflow-nest/nest-local-builder#nestbuilderoptions) — all builder options are accepted, plus `skipBuild`:

| Option       | Type                  | Default                                         | Description                                                                                                                                                                        |
| ------------ | --------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `skipBuild`  | `boolean`             | `false`                                         | Skip building workflow bundles on startup. Useful in production when the bundles are pre-built.                                                                                    |
| `workingDir` | `string`              | `process.cwd()`                                 | Working directory for the NestJS application.                                                                                                                                      |
| `dirs`       | `string[]`            | `['src']`                                       | Directories to scan for workflow files.                                                                                                                                            |
| `outDir`     | `string`              | `'.nestjs/workflow'` (relative to `workingDir`) | Output directory for generated workflow bundles.                                                                                                                                   |
| `watch`      | `boolean`             | `false`                                         | Enable watch mode for development.                                                                                                                                                 |
| `moduleType` | `'es6' \| 'commonjs'` | `'es6'`                                         | SWC module compilation type. Set to `'commonjs'` if your NestJS project compiles to CJS via SWC.                                                                                   |
| `distDir`    | `string`              | `'dist'`                                        | Directory where NestJS compiles `.ts` source files to `.js` (relative to `workingDir`). Used when `moduleType` is `'commonjs'`. Should match the `outDir` in your `tsconfig.json`. |

### Returns

`forRoot()` returns a `DynamicModule` to include in the `imports` array of your root module.


---
title: workflow/next
description: Next.js integration for automatic bundling and runtime configuration.
type: overview
summary: Explore the Next.js integration for automatic workflow bundling and runtime support.
related:
  - /docs/getting-started/next
---

# workflow/next



Next.js integration for Workflow SDK that automatically configures bundling and runtime support.

## Functions

<Cards>
  <Card title="withWorkflow" href="/docs/api-reference/workflow-next/with-workflow">
    Configures webpack/turbopack loaders to transform workflow code (`"use step"`/`"use workflow"` directives)
  </Card>
</Cards>


---
title: withWorkflow
description: Configure webpack/turbopack to transform workflow directives in Next.js.
type: reference
summary: Wrap your Next.js config with withWorkflow to enable workflow directive transformation.
prerequisites:
  - /docs/getting-started/next
---

# withWorkflow



Configures webpack/turbopack loaders to transform workflow code (`"use step"`/`"use workflow"` directives)

## Usage

To enable `"use step"` and `"use workflow"` directives while developing locally or deploying to production, wrap your `nextConfig` with `withWorkflow`.

```typescript title="next.config.ts" lineNumbers
import { withWorkflow } from "workflow/next"; // [!code highlight]
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  // … rest of your Next.js config
};

// not required but allows configuring workflow options
const workflowConfig = {} 

export default withWorkflow(nextConfig, workflowConfig); // [!code highlight]
```

<Callout type="warn">
  If a package in `serverExternalPackages` contains workflow code (`"use step"`,
  `"use workflow"`, or serialization classes), `withWorkflow()` automatically
  removes it from `serverExternalPackages` for the current build and prints a
  warning. Workflow still compiles the package so its directives are transformed.
  Remove that package from `serverExternalPackages` in your
  `next.config` to silence the warning.
</Callout>

### Monorepos and Workspace Imports

By default, Next.js detects the correct workspace root automatically. If your Next.js app lives in a subdirectory such as `apps/web` and workspace resolution is not working correctly, you can set `outputFileTracingRoot` as a workaround:

```typescript title="apps/web/next.config.ts" lineNumbers
import { resolve } from "node:path";
import type { NextConfig } from "next";
import { withWorkflow } from "workflow/next";

const nextConfig: NextConfig = {
  outputFileTracingRoot: resolve(process.cwd(), "../.."),
};

export default withWorkflow(nextConfig);
```

<Callout type="info">
  Use the smallest directory that contains every workspace package imported by your workflows. If your app already lives at the repository root, you do not need to set `outputFileTracingRoot`.
</Callout>

## Options

`withWorkflow` accepts an optional second argument to configure the Next.js integration.

```typescript title="next.config.ts" lineNumbers
import type { NextConfig } from "next";
import { withWorkflow } from "workflow/next";

const nextConfig: NextConfig = {};

export default withWorkflow(nextConfig, {
  workflows: {
    local: {
      port: 4000,
    },
    sourcemap: false,
  },
});
```

| Option                 | Type                                                      | Default                           | Description                                                                                             |
| ---------------------- | --------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `workflows.local.port` | `number`                                                  | —                                 | Overrides the `PORT` environment variable for local development. Has no effect when deployed to Vercel. |
| `workflows.sourcemap`  | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. See [Source maps](#source-maps) below.              |

### Source maps

The step bundle and intermediate workflow bundle default to `'inline'` source maps **in development** — so stack traces from step errors and workflow VM errors point at your source files — and to **`false` in production**, so function bundles stay small. The `sourcemap` option lets you change that:

| Value               | Behavior                                                                           |
| ------------------- | ---------------------------------------------------------------------------------- |
| `true` / `'inline'` | Base64-encode the source map and append it to the bundle (default in development). |
| `'linked'`          | Write a separate `.map` file and add a `sourceMappingURL` comment.                 |
| `'external'`        | Write a separate `.map` file without the comment.                                  |
| `'both'`            | Emit both inline and external source maps.                                         |
| `false`             | Omit source maps entirely.                                                         |

In production, source maps are already off by default. Setting `sourcemap: false` explicitly also turns them off in development, and it drops the inline source map from every bundle while skipping the source-map-support runtime shim on the Vercel step function (the same behavior production gets by default) — the main lever for staying under the Vercel 250MB function size limit. The tradeoff is that workflow VM stack traces will reference generated code (e.g. `evalmachine.<anonymous>`) rather than your source files.

<Callout type="info">
  Setting `sourcemap` explicitly affects **all** generated bundles (steps, workflows, webhook). The legacy `WORKFLOW_EMIT_SOURCEMAPS_FOR_DEBUGGING=1` environment variable is narrower — it only toggles source maps on the final workflow wrapper and webhook bundle (which default to off). It continues to work, but new code should use the `sourcemap` option or the `WORKFLOW_SOURCEMAP` environment variable instead.
</Callout>

The option can also be set via the `WORKFLOW_SOURCEMAP` environment variable, which accepts the same values plus `'0'` / `'1'` as aliases for `false` / `true`. Precedence is: explicit config > `WORKFLOW_SOURCEMAP` > the environment-aware default (`'inline'` in development, `false` in production). Development is detected from `next dev` / `NODE_ENV=development`, so the config option and the env var both let you force either behavior in either environment.

<Callout type="info">
  The `workflows.local` options only affect local development. When deployed to Vercel, the runtime ignores `local` settings and uses the Vercel world automatically.
</Callout>

## Exporting a Function

If you are exporting a function in your `next.config` you will need to ensure you call the function returned from `withWorkflow`.

```typescript title="next.config.ts" lineNumbers
import { NextConfig } from "next";
import { withWorkflow } from "workflow/next";
import createNextIntlPlugin from "next-intl/plugin";

const withNextIntl = createNextIntlPlugin();

export default async function config(
  phase: string,
  ctx: {
    defaultConfig: NextConfig
  }
): Promise<NextConfig> {
  let nextConfig: NextConfig | typeof config = {};

  for (const configModifier of [withNextIntl, withWorkflow]) {
    nextConfig = configModifier(nextConfig);

    if (typeof nextConfig === "function") {
      nextConfig = await nextConfig(phase, ctx);
    }
  }
  return nextConfig;
}
```


---
title: workflow/nitro
description: Nitro module for automatic workflow bundling and route registration.
type: overview
summary: Explore the Nitro module that enables workflow directive transformation in Nitro apps.
related:
  - /docs/getting-started/nitro
---

# workflow/nitro



Nitro integration for Workflow SDK. The `workflow/nitro` entry point's default export is a [Nitro module](https://v3.nitro.build/guide/modules) — it has no callable API. You enable it by adding it to the `modules` array of your Nitro config and configure it via the `workflow` key.

## Usage

```typescript title="nitro.config.ts" lineNumbers
import { defineConfig } from "nitro";

export default defineConfig({
  serverDir: "./server",
  modules: ["workflow/nitro"], // [!code highlight]
});
```

When enabled, the module:

* Transforms `"use workflow"` and `"use step"` directives during bundling.
* Builds the workflow, step, and webhook bundles, and rebuilds them on file changes in development.
* Registers the workflow runtime routes under `/.well-known/workflow/v1/`.
* Serves a redirect to the local observability dashboard at `/_workflow` in development.
* Configures Vercel function rules (queue triggers and `maxDuration`) for the workflow routes when deploying to Vercel.
* Uses Nitro's `workspaceDir` as the workflow project root so monorepo apps can import sibling workspace packages without extra workflow config.

## Module Options

Options are read from the `workflow` key of your Nitro config. The option type is exported as `ModuleOptions`:

```typescript title="nitro.config.ts" lineNumbers
import { defineConfig } from "nitro";
import type { ModuleOptions } from "workflow/nitro"; // [!code highlight]

const workflow: ModuleOptions = {
  runtime: "nodejs22.x",
};

export default defineConfig({
  modules: ["workflow/nitro"],
  workflow, // [!code highlight]
});
```

| Option             | Type       | Default | Description                                                                                                                                            |
| ------------------ | ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `dirs`             | `string[]` | —       | Directories to scan for workflows and steps. By default, the `workflows/` directory is scanned from the project root and all layer source directories. |
| `typescriptPlugin` | `boolean`  | `false` | Adds the `workflow` TypeScript plugin to the generated `tsconfig.json` for IDE IntelliSense.                                                           |
| `runtime`          | `string`   | —       | Node.js runtime version for Vercel Functions (e.g. `'nodejs22.x'`, `'nodejs24.x'`). Only applies when deploying to Vercel.                             |

## Vite-based Nitro

If you use Nitro through its Vite plugin (`nitro/vite`) instead of a standalone `nitro.config.ts`, use the [`workflow/vite`](/docs/api-reference/workflow-vite) entry point, which wraps this module as a Vite plugin and accepts the same `ModuleOptions`.


---
title: workflow/nuxt
description: Nuxt module for automatic workflow bundling and runtime configuration.
type: overview
summary: Explore the Nuxt module that enables workflow directive transformation in Nuxt apps.
related:
  - /docs/getting-started/nuxt
---

# workflow/nuxt



Nuxt integration for Workflow SDK. The `workflow/nuxt` entry point's default export is a Nuxt module — it has no callable API. You enable it by adding it to the `modules` array of your Nuxt config and configure it via the `workflow` key.

## Usage

```typescript title="nuxt.config.ts" lineNumbers
import { defineNuxtConfig } from "nuxt/config";

export default defineNuxtConfig({
  modules: ["workflow/nuxt"], // [!code highlight]
  compatibilityDate: "latest",
});
```

When enabled, the module:

* Registers the [`workflow/nitro`](/docs/api-reference/workflow-nitro) module on Nuxt's Nitro server, which transforms `"use workflow"` and `"use step"` directives, builds the workflow bundles, and registers the workflow runtime routes under `/.well-known/workflow/v1/`.
* Configures Vite to bundle (rather than externalize) the Workflow SDK packages in SSR mode so workflow code is transformed correctly.
* Enables the `workflow` TypeScript plugin by default for IDE IntelliSense.
* Uses Nuxt/Nitro's detected `workspaceDir` so monorepo apps can import sibling workspace packages without extra workflow config.

## Module Options

Options are read from the `workflow` key of your Nuxt config. The option type is exported as `ModuleOptions`:

```typescript title="nuxt.config.ts" lineNumbers
import { defineNuxtConfig } from "nuxt/config";

export default defineNuxtConfig({
  modules: ["workflow/nuxt"],
  workflow: {
    typescriptPlugin: false, // [!code highlight]
  },
  compatibilityDate: "latest",
});
```

| Option             | Type      | Default | Description                                                                                                                |
| ------------------ | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------- |
| `typescriptPlugin` | `boolean` | `true`  | Adds the `workflow` TypeScript plugin to the generated `tsconfig.json` for IDE IntelliSense. Set to `false` to disable it. |


---
title: hydrateData
description: Hydrate a single serialized value from workflow storage into a plain JavaScript value.
type: reference
summary: Use hydrateData to deserialize a single value when hydrateResourceIO's field mapping doesn't apply.
related:
  - /docs/api-reference/workflow-observability/hydrate-resource-io
  - /docs/api-reference/workflow-observability/observability-revivers
---

# hydrateData



Hydrates (deserializes) a single value that was stored by the workflow runtime. This is the lower-level building block behind [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io) — use it when you have a raw serialized value rather than a whole resource, such as a single field from an event payload.

```typescript lineNumbers
import { hydrateData, observabilityRevivers } from "workflow/observability"; // [!code highlight]
declare const serialized: unknown; // @setup

const value = hydrateData(serialized, observabilityRevivers); // [!code highlight]
```

## API Signature

### Parameters

| Parameter  | Type       | Description                                                                                                                                               |
| ---------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `value`    | `unknown`  | The serialized value from workflow storage                                                                                                                |
| `revivers` | `Revivers` | Reviver functions for deserialization. Use [`observabilityRevivers`](/docs/api-reference/workflow-observability/observability-revivers) for standard use. |

### Returns

The hydrated plain JavaScript value. The input is handled by shape:

* Format-prefixed binary data (`Uint8Array`) is decoded and parsed from the [devalue](https://github.com/Rich-Harris/devalue) format
* Encrypted data is returned as-is (a raw `Uint8Array`) — see [Encrypted Data](/docs/api-reference/workflow-observability#encrypted-data)
* Already-plain values (numbers, strings, `null`) are returned unchanged


---
title: hydrateResourceIO
description: Hydrate the serialized data fields of a run, step, hook, or event for display.
type: reference
summary: Use hydrateResourceIO with observabilityRevivers to deserialize step input/output for display in observability tools.
prerequisites:
  - /docs/api-reference/workflow-runtime/get-world
related:
  - /docs/api-reference/workflow-observability/observability-revivers
  - /docs/api-reference/workflow-runtime/world/storage
---

# hydrateResourceIO



Hydrates (deserializes) the data fields of a resource returned by the [World SDK](/docs/api-reference/workflow-runtime/world) — a workflow run, step, hook, or event. Workflow data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format, so this is required before displaying step input/output in a UI.

The function dispatches on the resource shape: steps get `input`/`output` hydrated, hooks get `metadata`, events get `eventData`, and runs get `input`/`output`.

```typescript lineNumbers
import { getWorld } from "workflow/runtime";
import { hydrateResourceIO, observabilityRevivers } from "workflow/observability"; // [!code highlight]
declare const runId: string; // @setup
declare const stepId: string; // @setup

const world = await getWorld();
const step = await world.steps.get(runId, stepId);
const hydrated = hydrateResourceIO(step, observabilityRevivers); // [!code highlight]
console.log(hydrated.input, hydrated.output);
```

## API Signature

### Parameters

| Parameter  | Type                                   | Description                                                                                                                                               |
| ---------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `resource` | `WorkflowRun \| Step \| Hook \| Event` | The resource with serialized data fields                                                                                                                  |
| `revivers` | `Revivers`                             | Reviver functions for deserialization. Use [`observabilityRevivers`](/docs/api-reference/workflow-observability/observability-revivers) for standard use. |

### Returns

The same resource with its data fields hydrated into plain JavaScript values.

<Callout type="info">
  Encrypted data fields pass through as raw `Uint8Array` values rather than being decrypted — see [Encrypted Data](/docs/api-reference/workflow-observability#encrypted-data).
</Callout>

## Examples

### Display a Run's Steps with Hydrated I/O

```typescript lineNumbers
import { getWorld } from "workflow/runtime";
import { hydrateResourceIO, observabilityRevivers } from "workflow/observability"; // [!code highlight]
declare const runId: string; // @setup

const world = await getWorld();
const steps = await world.steps.list({ runId, resolveData: "all" });

for (const step of steps.data) {
  const hydrated = hydrateResourceIO(step, observabilityRevivers); // [!code highlight]
  console.log(step.stepName, hydrated.input, hydrated.output);
}
```


---
title: workflow/observability
description: Utilities to hydrate serialized step I/O and parse machine-readable workflow names for display.
type: overview
summary: Explore utilities for hydrating serialized workflow data and parsing display names in observability tools.
---

# workflow/observability



API reference for observability utilities from the `workflow/observability` package.

The observability package provides utilities for working with workflow data in observability and debugging tools — hydrating serialized step I/O for display, and parsing machine-readable names into display-friendly formats.

```typescript lineNumbers
import { // [!code highlight]
  hydrateResourceIO, // [!code highlight]
  observabilityRevivers, // [!code highlight]
  hydrateData, // [!code highlight]
  parseStepName, // [!code highlight]
  parseWorkflowName, // [!code highlight]
  parseClassName, // [!code highlight]
} from "workflow/observability"; // [!code highlight]
```

## Data Hydration

<Cards>
  <Card href="/docs/api-reference/workflow-observability/hydrate-resource-io" title="hydrateResourceIO()">
    Hydrate the serialized data fields of a run, step, hook, or event for display.
  </Card>

  <Card href="/docs/api-reference/workflow-observability/observability-revivers" title="observabilityRevivers">
    Standard revivers for deserializing workflow data types (Date, Map, Set, streams, etc.).
  </Card>

  <Card href="/docs/api-reference/workflow-observability/hydrate-data" title="hydrateData()">
    Hydrate a single serialized value (lower-level than hydrateResourceIO).
  </Card>
</Cards>

## Name Parsing

<Cards>
  <Card href="/docs/api-reference/workflow-observability/parse-step-name" title="parseStepName()">
    Parse a machine-readable step name into display-friendly components.
  </Card>

  <Card href="/docs/api-reference/workflow-observability/parse-workflow-name" title="parseWorkflowName()">
    Parse a machine-readable workflow name into display-friendly components.
  </Card>

  <Card href="/docs/api-reference/workflow-observability/parse-class-name" title="parseClassName()">
    Parse a machine-readable class ID into display-friendly components.
  </Card>
</Cards>

## Encrypted Data

When a [World](/docs/api-reference/workflow-runtime/world) stores encrypted data, the hydration utilities intentionally leave encrypted values untouched: [`hydrateData()`](/docs/api-reference/workflow-observability/hydrate-data) and [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io) return encrypted fields as raw `Uint8Array` values so observability tools can detect them and decide how to render them (for example, the Workflow CLI shows an "Encrypted" placeholder). Decryption is handled by the runtime and the World implementation — see [Encryption](/docs/how-it-works/encryption) for how keys are managed.


---
title: observabilityRevivers
description: Standard reviver functions for deserializing workflow data types in observability tools.
type: reference
summary: Pass observabilityRevivers to hydrateResourceIO or hydrateData to deserialize standard workflow data types.
related:
  - /docs/api-reference/workflow-observability/hydrate-resource-io
  - /docs/api-reference/workflow-observability/hydrate-data
---

# observabilityRevivers



A set of reviver functions that handle the workflow serialization format's workflow-specific types — streams, step/workflow function references, class instances, `AbortController`/`AbortSignal`, and `DOMException` — reviving them as display-friendly marker objects or strings. Built-in JavaScript types (`Date`, `Map`, `Set`, `RegExp`, etc.) are handled by the devalue format itself and need no revivers.

Pass it as the `revivers` argument to [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io) or [`hydrateData()`](/docs/api-reference/workflow-observability/hydrate-data).

```typescript lineNumbers
import { hydrateResourceIO, observabilityRevivers } from "workflow/observability"; // [!code highlight]
import type { Step } from "@workflow/world";
declare const step: Step; // @setup

const hydrated = hydrateResourceIO(step, observabilityRevivers); // [!code highlight]
```

## API Signature

```typescript
import type { Revivers } from "workflow/observability";

declare const observabilityRevivers: Revivers;
```

Where `Revivers` is:

```typescript
type Revivers = Record<string, (value: any) => any>;
```

Each key is a serialized type tag, and each function revives a serialized value of that type. You can spread `observabilityRevivers` into a custom reviver map to override how specific types are displayed:

```typescript lineNumbers
import { hydrateData, observabilityRevivers } from "workflow/observability";
declare const value: unknown; // @setup

const customRevivers = {
  ...observabilityRevivers,
  // Render stream references as plain strings instead of marker objects
  ReadableStream: () => "<stream>",
};

const hydrated = hydrateData(value, customRevivers);
```


---
title: parseClassName
description: Parse a machine-readable class ID into display-friendly components.
type: reference
summary: Use parseClassName to extract a display-friendly class name from a serialized class instance identifier.
related:
  - /docs/api-reference/workflow-observability/parse-step-name
  - /docs/api-reference/workflow-observability/parse-workflow-name
  - /docs/api-reference/workflow-serde
---

# parseClassName



Serialized class instances reference their class with machine-readable IDs like `class//./src/models//User`. This function parses them into components suitable for display in a UI.

```typescript lineNumbers
import { parseClassName } from "workflow/observability"; // [!code highlight]

const parsed = parseClassName("class//./src/models//User"); // [!code highlight]
// parsed?.shortName → "User"
// parsed?.moduleSpecifier → "./src/models"
// parsed?.functionName → "User"
```

## API Signature

### Parameters

| Parameter | Type     | Description                   |
| --------- | -------- | ----------------------------- |
| `name`    | `string` | The machine-readable class ID |

### Returns

`{ shortName: string; moduleSpecifier: string; functionName: string } | null`

| Property          | Description                                                                                                   |
| ----------------- | ------------------------------------------------------------------------------------------------------------- |
| `shortName`       | The display name of the class (e.g. `"User"`).                                                                |
| `moduleSpecifier` | The module the class is defined in — a relative path (`./src/models`) or a package specifier (`point@0.0.1`). |
| `functionName`    | The class name as recorded by the compiler.                                                                   |

Returns `null` when the input is not a valid class ID.


---
title: parseStepName
description: Parse a machine-readable step name into display-friendly components.
type: reference
summary: Use parseStepName to extract a display-friendly short name from a step's machine-readable identifier.
related:
  - /docs/api-reference/workflow-observability/parse-workflow-name
  - /docs/api-reference/workflow-observability/parse-class-name
---

# parseStepName



Step names are stored as machine-readable identifiers like `step//./src/workflows/order//processPayment`. This function parses them into components suitable for display in a UI.

```typescript lineNumbers
import { parseStepName } from "workflow/observability"; // [!code highlight]

const parsed = parseStepName("step//./src/workflows/order//processPayment"); // [!code highlight]
// parsed?.shortName → "processPayment"
// parsed?.moduleSpecifier → "./src/workflows/order"
// parsed?.functionName → "processPayment"
```

## API Signature

### Parameters

| Parameter | Type     | Description                                                |
| --------- | -------- | ---------------------------------------------------------- |
| `name`    | `string` | The machine-readable step name (e.g. from `step.stepName`) |

### Returns

`{ shortName: string; moduleSpecifier: string; functionName: string } | null`

| Property          | Description                                                                                                                        |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `shortName`       | The display name — the last segment of the function name. For nested steps like `processOrder/chargeCard`, this is `"chargeCard"`. |
| `moduleSpecifier` | The module the step is defined in — a relative path (`./src/workflows/order`) or a package specifier (`@myorg/tasks@2.0.0`).       |
| `functionName`    | The full function name including nesting (e.g. `processOrder/chargeCard`).                                                         |

Returns `null` when the input is not a valid step name.


---
title: parseWorkflowName
description: Parse a machine-readable workflow name into display-friendly components.
type: reference
summary: Use parseWorkflowName to extract a display-friendly short name from a run's workflowName identifier.
related:
  - /docs/api-reference/workflow-observability/parse-step-name
  - /docs/api-reference/workflow-observability/parse-class-name
---

# parseWorkflowName



Workflow names are stored as machine-readable identifiers like `workflow//./src/workflows/order//processOrder`. This function parses them into components suitable for display in a UI — for example when listing runs from the [World SDK](/docs/api-reference/workflow-runtime/world/storage), where `run.workflowName` holds the machine-readable form.

```typescript lineNumbers
import { parseWorkflowName } from "workflow/observability"; // [!code highlight]

const parsed = parseWorkflowName("workflow//./src/workflows/order//processOrder"); // [!code highlight]
// parsed?.shortName → "processOrder"
// parsed?.moduleSpecifier → "./src/workflows/order"
// parsed?.functionName → "processOrder"
```

## API Signature

### Parameters

| Parameter | Type     | Description                                                       |
| --------- | -------- | ----------------------------------------------------------------- |
| `name`    | `string` | The machine-readable workflow name (e.g. from `run.workflowName`) |

### Returns

`{ shortName: string; moduleSpecifier: string; functionName: string } | null`

| Property          | Description                                                                                                                      |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `shortName`       | The display name. For default exports, falls back to the module's short name (e.g. `"order"` for `./src/workflows/order`).       |
| `moduleSpecifier` | The module the workflow is defined in — a relative path (`./src/workflows/order`) or a package specifier (`@myorg/flows@1.0.0`). |
| `functionName`    | The full exported function name.                                                                                                 |

Returns `null` when the input is not a valid workflow name.

## Example: List Runs with Display Names

```typescript lineNumbers
import { getWorld } from "workflow/runtime";
import { parseWorkflowName } from "workflow/observability"; // [!code highlight]

const world = await getWorld();
const runs = await world.runs.list({ resolveData: "none" });

for (const run of runs.data) {
  const parsed = parseWorkflowName(run.workflowName); // [!code highlight]
  console.log(`${parsed?.shortName ?? run.workflowName}: ${run.status}`);
}
```


---
title: createWorld
description: Create a new World instance from environment configuration.
type: reference
summary: Use createWorld to instantiate a World from WORKFLOW_TARGET_WORLD environment configuration, bypassing the cached instance.
prerequisites:
  - /docs/api-reference/workflow-runtime/get-world
related:
  - /docs/api-reference/workflow-runtime/set-world
---

# createWorld



Creates a new [World](/docs/api-reference/workflow-runtime/world) instance based on environment configuration. The `WORKFLOW_TARGET_WORLD` environment variable determines which World implementation is instantiated (for example the local development World or the Vercel production World).

Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), which caches a singleton instance, `createWorld()` constructs a fresh instance on every call. Application code should almost always use `getWorld()` — `createWorld()` is for infrastructure code that manages World lifecycles itself.

```typescript lineNumbers
import { createWorld } from "workflow/runtime";

const world = await createWorld(); // [!code highlight]
```

## API Signature

### Parameters

This function does not accept any parameters. Configuration is read from environment variables.

### Returns

Returns a newly constructed `World` instance.

<Callout type="info">
  In workflow 4.x, `createWorld()` is synchronous and returns `World` directly. It becomes async in 5.x, so writing `await createWorld()` works on both versions.
</Callout>

<Callout type="info">
  Tooling that needs to construct a World with explicit (non-environment) configuration should instantiate the specific World implementation directly and register it with [`setWorld()`](/docs/api-reference/workflow-runtime/set-world).
</Callout>

## Related Functions

* [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the cached World instance (preferred in application code).
* [`setWorld()`](/docs/api-reference/workflow-runtime/set-world) - Override the cached World instance.


---
title: getWorldHandlers
description: Build-time-safe access to the World's queue handlers without binding to runtime environment variables.
type: reference
summary: Use getWorldHandlers at build time to access queue handler creation without caching an environment-bound World.
prerequisites:
  - /docs/api-reference/workflow-runtime/get-world
---

# getWorldHandlers



Returns a restricted view of the [World](/docs/api-reference/workflow-runtime/world) exposing only the members that are safe to use at build time: `createQueueHandler` and `specVersion`. Framework adapters use it while generating workflow route handlers, before the deployment's runtime environment variables exist.

Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), this function does not cache a fully configured World instance — caching at build time would lock in incomplete environment configuration.

```typescript lineNumbers
import { getWorldHandlers } from "workflow/runtime";

const handlers = await getWorldHandlers(); // [!code highlight]
console.log(handlers.specVersion);
```

## API Signature

### Parameters

This function does not accept any parameters.

### Returns

Returns a `WorldHandlers` object (synchronously in workflow 4.x; async in 5.x), where:

```typescript
import type { World } from "@workflow/world";

type WorldHandlers = Pick<World, "createQueueHandler" | "specVersion">;
```

<Callout type="warn">
  This is SDK infrastructure used by framework adapters and the workflow entrypoint. Application code should use [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) instead.
</Callout>

## Related Functions

* [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the full World instance at runtime.
* [`workflowEntrypoint()`](/docs/api-reference/workflow-runtime/workflow-entrypoint) - The route handler factory built on these handlers.


---
title: getWorld
description: Resolves the World instance for low-level storage, queuing, and streaming operations.
type: reference
summary: Resolve the World instance for low-level workflow storage, queuing, and streaming backends.
prerequisites:
  - /docs/deploying
---

# getWorld



Retrieves the World instance for direct access to workflow storage, queuing, and streaming backends. The returned `World` provides low-level access to manage workflow runs, steps, events, and hooks.

Use this function when you need direct access to the underlying workflow infrastructure, such as listing all runs, querying events, or implementing custom workflow management logic.

```typescript lineNumbers
import { getWorld } from "workflow/runtime";

const world = await getWorld(); // [!code highlight]
```

<Callout type="info">
  In workflow 4.x, `getWorld()` is synchronous and returns `World` directly. It becomes async in 5.x, so writing `await getWorld()` works on both versions.
</Callout>

## API Signature

### Parameters

This function does not accept any parameters.

### Returns

Returns the `World` object:

<TSDoc
  definition={`
import type { World } from "@workflow/world";
export default World;`}
  showSections={["returns"]}
/>

## World SDK

The World object provides access to several entity interfaces. See the [World SDK](/docs/api-reference/workflow-runtime/world) reference for complete documentation:

<Cards>
  <Card href="/docs/api-reference/workflow-runtime/world/storage" title="Storage">
    Query runs, steps, hooks, and the underlying event log.
  </Card>

  <Card href="/docs/api-reference/workflow-runtime/world/streams" title="Streams">
    Read, write, and manage data streams.
  </Card>

  <Card href="/docs/api-reference/workflow-runtime/world/queue" title="Queue">
    Low-level queue dispatch (internal SDK infrastructure).
  </Card>
</Cards>

## Data Hydration

Step and run data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. Use `workflow/observability` to hydrate it for display:

```typescript lineNumbers
import { hydrateResourceIO, observabilityRevivers } from "workflow/observability"; // [!code highlight]

const step = await world.steps.get(runId, stepId);
const hydrated = hydrateResourceIO(step, observabilityRevivers); // [!code highlight]
```

See [`workflow/observability`](/docs/api-reference/workflow-observability) for the full hydration and parsing API.

### List Workflow Runs (Display Names)

List workflow runs and derive human-readable names from the `workflowName` field:

```typescript lineNumbers
import { getWorld } from "workflow/runtime";
import { parseWorkflowName } from "workflow/observability"; // [!code highlight]

export async function GET(req: Request) {
  const url = new URL(req.url);
  const cursor = url.searchParams.get("cursor") ?? undefined;

  try {
    const world = await getWorld(); // [!code highlight]
    const runs = await world.runs.list({
      pagination: { cursor },
      resolveData: "none",
    });

    return Response.json({
      data: runs.data.map((run) => {
        const parsed = parseWorkflowName(run.workflowName); // [!code highlight]

        return {
          runId: run.runId,
          // Use shortName for UI display (e.g., "processOrder") // [!code highlight]
          displayName: parsed?.shortName ?? run.workflowName, // [!code highlight]
          // Module info available for debugging // [!code highlight]
          module: parsed?.moduleSpecifier, // [!code highlight]
          status: run.status,
          startedAt: run.startedAt,
          completedAt: run.completedAt,
        };
      }),
      cursor: runs.cursor,
    });
  } catch (error) {
    return Response.json(
      { error: "Failed to list workflow runs" },
      { status: 500 }
    );
  }
}
```

<Callout type="info">
  The `workflowName` field contains a machine-readable identifier like `workflow//./src/workflows/order//processOrder`.
  Use [`parseWorkflowName()`](/docs/api-reference/workflow-observability/parse-workflow-name) to extract the `shortName` (e.g., `"processOrder"`)
  and `moduleSpecifier` for display in your UI.
</Callout>

## Related Functions

* [`getRun()`](/docs/api-reference/workflow-api/get-run) - Higher-level API for working with individual runs by ID.
* [`start()`](/docs/api-reference/workflow-api/start) - Start a new workflow run.


---
title: healthCheck
description: Verify a deployment's workflow infrastructure by sending a message through the queue pipeline.
type: reference
summary: Use healthCheck to verify the workflow endpoint of a deployment processes queue messages end-to-end.
prerequisites:
  - /docs/api-reference/workflow-runtime/get-world
---

# healthCheck



Performs an end-to-end health check of a deployment's workflow infrastructure by sending a message through the queue pipeline and verifying it is processed by the workflow endpoint. Because it goes through the queue rather than direct HTTP, it works even when the deployment is behind Deployment Protection on Vercel.

```typescript lineNumbers
import { getWorld, healthCheck } from "workflow/runtime";

const world = await getWorld();
const result = await healthCheck(world, "workflow"); // [!code highlight]

if (!result.healthy) {
  console.error("Workflow infrastructure unhealthy:", result.error);
}
```

## API Signature

### Parameters

| Parameter  | Type                                          | Description                                         |
| ---------- | --------------------------------------------- | --------------------------------------------------- |
| `world`    | `World`                                       | The World instance to send the health check through |
| `endpoint` | `"workflow" \| "step"`                        | Which endpoint to check                             |
| `options`  | `HealthCheckOptions & { namespace?: string }` | Optional configuration                              |

Where `HealthCheckOptions` is:

| Option         | Type     | Description                                                             |
| -------------- | -------- | ----------------------------------------------------------------------- |
| `timeout`      | `number` | Milliseconds to wait for the health check response. Default: `30000`.   |
| `deploymentId` | `string` | Deployment to target. Falls back to `process.env.VERCEL_DEPLOYMENT_ID`. |

### Returns

Returns a `Promise<HealthCheckResult>`:

| Property              | Type                  | Description                                             |
| --------------------- | --------------------- | ------------------------------------------------------- |
| `healthy`             | `boolean`             | Whether the endpoint processed the health check message |
| `error`               | `string \| undefined` | Error message when the check failed                     |
| `latencyMs`           | `number \| undefined` | Round-trip latency when the check succeeded             |
| `specVersion`         | `number \| undefined` | Workflow spec version of the responding deployment      |
| `workflowCoreVersion` | `string \| undefined` | `@workflow/core` version of the responding deployment   |


---
title: workflow/runtime
description: Runtime functions for accessing the World instance and wiring up workflow infrastructure.
type: overview
summary: Explore runtime functions for resolving the World instance and configuring workflow infrastructure.
---

# workflow/runtime



API reference for runtime functions from the `workflow/runtime` package.

The runtime package provides low-level access to the workflow runtime — resolving the [World](/docs/api-reference/workflow-runtime/world) instance that backs storage, queuing, and streaming, and wiring up workflow infrastructure in custom server environments.

## Functions

<Cards>
  <Card href="/docs/api-reference/workflow-runtime/get-world" title="getWorld()">
    Async: resolve the World instance for storage, queuing, and streaming backends.
  </Card>

  <Card href="/docs/api-reference/workflow-runtime/world" title="World SDK">
    Low-level API for inspecting runs, steps, events, hooks, streams, and queues.
  </Card>
</Cards>

## Infrastructure Functions

These functions are primarily used by framework adapters and custom world setups, and are rarely needed in application code:

<Cards>
  <Card href="/docs/api-reference/workflow-runtime/create-world" title="createWorld()">
    Create a World instance from environment configuration.
  </Card>

  <Card href="/docs/api-reference/workflow-runtime/set-world" title="setWorld()">
    Override the cached World instance with a custom World.
  </Card>

  <Card href="/docs/api-reference/workflow-runtime/get-world-handlers" title="getWorldHandlers()">
    Build-time-safe access to the World's queue handlers.
  </Card>

  <Card href="/docs/api-reference/workflow-runtime/workflow-entrypoint" title="workflowEntrypoint()">
    Create the HTTP route handler that executes workflow runs.
  </Card>

  <Card href="/docs/api-reference/workflow-runtime/step-entrypoint" title="stepEntrypoint">
    The HTTP route handler that executes step functions.
  </Card>

  <Card href="/docs/api-reference/workflow-runtime/health-check" title="healthCheck()">
    Check the health of a deployment's workflow infrastructure.
  </Card>
</Cards>


---
title: setWorld
description: Override or reset the cached World instance used by the workflow runtime.
type: reference
summary: Use setWorld to inject a custom World instance or reset the cache after environment configuration changes.
prerequisites:
  - /docs/api-reference/workflow-runtime/get-world
related:
  - /docs/api-reference/workflow-runtime/create-world
---

# setWorld



Overrides the cached [World](/docs/api-reference/workflow-runtime/world) instance that [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) returns. Use it to inject a World constructed with explicit configuration (rather than environment variables), or pass `undefined` to clear the cache so the next `getWorld()` call reinitializes from the current environment.

```typescript lineNumbers
import { setWorld, getWorld } from "workflow/runtime";
import type { World } from "@workflow/world";
declare const customWorld: World; // @setup

setWorld(customWorld); // [!code highlight]
const world = await getWorld(); // resolves customWorld
```

## API Signature

### Parameters

| Parameter | Type                 | Description                                                                                                             |
| --------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `world`   | `World \| undefined` | The World instance to use, or `undefined` to reset the cache and reinitialize from environment variables on next access |

### Returns

This function does not return a value.

## Example: Reset After Environment Changes

```typescript lineNumbers
import { setWorld, getWorld } from "workflow/runtime";

process.env.WORKFLOW_TARGET_WORLD = "@workflow/world-local";
setWorld(undefined); // clear the cached instance // [!code highlight]

const world = await getWorld(); // reinitialized with new configuration
```

## Related Functions

* [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the cached World instance.
* [`createWorld()`](/docs/api-reference/workflow-runtime/create-world) - Construct a fresh World from environment configuration.


---
title: stepEntrypoint
description: The HTTP route handler that executes step functions.
type: reference
summary: Mount stepEntrypoint as the route that executes step functions in custom server environments.
prerequisites:
  - /docs/how-it-works/code-transform
related:
  - /docs/api-reference/workflow-runtime/workflow-entrypoint
---

# stepEntrypoint



The HTTP route handler that executes step functions. It receives step execution requests from the queue, routes them to the appropriate step function, and reports results back to the workflow run.

Unlike [`workflowEntrypoint()`](/docs/api-reference/workflow-runtime/workflow-entrypoint), this is the handler itself rather than a factory — step bundles register their step functions globally, and the handler routes by step name.

Framework adapters mount this for you at `/.well-known/workflow/v1/step` — you only need it when wiring workflow support into a custom server environment.

{/* @skip-typecheck: stepEntrypoint exists in workflow@4 only; docs samples are type-checked against the v5 packages on main */}

```typescript lineNumbers
import { stepEntrypoint } from "workflow/runtime";

// Mount on your server, e.g. a fetch-style route:
export const POST = stepEntrypoint; // [!code highlight]
```

## API Signature

{/* @skip-typecheck: type-only signature snippet, not compilable code */}

```typescript
const stepEntrypoint: (req: Request) => Promise<Response>;
```

A fetch-style request handler.

<Callout type="info">
  `stepEntrypoint` exists in workflow 4.x only. In 5.x the combined handler created by [`workflowEntrypoint()`](/docs/api-reference/workflow-runtime/workflow-entrypoint) executes steps inline, and the separate step endpoint was removed.
</Callout>


---
title: workflowEntrypoint
description: Create the HTTP route handler that executes workflow runs from a workflow bundle.
type: reference
summary: Use workflowEntrypoint to wire a compiled workflow bundle into an HTTP route in custom server environments.
prerequisites:
  - /docs/how-it-works/code-transform
related:
  - /docs/api-reference/workflow-runtime/health-check
---

# workflowEntrypoint



Creates the HTTP route handler that executes workflow runs. The handler receives queue messages, replays the workflow from its event log, executes steps inline where possible, and suspends when the workflow waits on sleeps or hooks.

Framework adapters (Next.js, Nitro, SvelteKit, etc.) call this for you and mount the result at `/.well-known/workflow/v1/flow` — you only need it when wiring workflow support into a custom server environment.

```typescript lineNumbers
import { workflowEntrypoint } from "workflow/runtime";
declare const workflowBundleCode: string; // @setup

const handler = workflowEntrypoint(workflowBundleCode); // [!code highlight]

// Mount on your server, e.g. a fetch-style route:
export const POST = (req: Request) => handler(req);
```

## API Signature

### Parameters

| Parameter      | Type                     | Description                                                          |
| -------------- | ------------------------ | -------------------------------------------------------------------- |
| `workflowCode` | `string`                 | The compiled workflow bundle code containing all workflow functions  |
| `options`      | `{ namespace?: string }` | Optional. `namespace` scopes the queue topics this handler consumes. |

### Returns

Returns a fetch-style request handler: `(req: Request) => Promise<Response>`.

## Related Functions

* [`getWorldHandlers()`](/docs/api-reference/workflow-runtime/get-world-handlers) - The build-time World access this handler is built on.
* [`healthCheck()`](/docs/api-reference/workflow-runtime/health-check) - Verify the entrypoint processes queue messages end-to-end.


---
title: @workflow/serde
---

# @workflow/serde



Serialization symbols for custom class serialization in Workflow SDK.

## Installation

<CodeBlockTabs defaultValue="npm">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="npm">
      npm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="pnpm">
      pnpm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="yarn">
      yarn
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="bun">
      bun
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="npm">
    ```bash
    npm i @workflow/serde
    ```
  </CodeBlockTab>

  <CodeBlockTab value="pnpm">
    ```bash
    pnpm add @workflow/serde
    ```
  </CodeBlockTab>

  <CodeBlockTab value="yarn">
    ```bash
    yarn add @workflow/serde
    ```
  </CodeBlockTab>

  <CodeBlockTab value="bun">
    ```bash
    bun add @workflow/serde
    ```
  </CodeBlockTab>
</CodeBlockTabs>

## Overview

By default, Workflow SDK can serialize standard JavaScript types like primitives, objects, arrays, `Date`, `Map`, `Set`, and more. However, custom class instances are not serializable by default because the serialization system doesn't know how to reconstruct them.

The `@workflow/serde` package provides two symbols that allow you to define custom serialization and deserialization logic for your classes, enabling them to be passed between workflow and step functions.

## Symbols

<Cards>
  <Card href="/docs/api-reference/workflow-serde/workflow-serialize" title="WORKFLOW_SERIALIZE">
    Symbol for defining how to serialize a class instance to plain data.
  </Card>

  <Card href="/docs/api-reference/workflow-serde/workflow-deserialize" title="WORKFLOW_DESERIALIZE">
    Symbol for defining how to reconstruct a class instance from plain data.
  </Card>
</Cards>

## Quick Example

```typescript lineNumbers
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";

class Point {
  constructor(public x: number, public y: number) {}

  static [WORKFLOW_SERIALIZE](instance: Point) {
    return { x: instance.x, y: instance.y };
  }

  static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) {
    return new Point(data.x, data.y);
  }
}
```

<Callout>
  For a complete guide on custom class serialization, see the [Serialization documentation](/docs/foundations/serialization#custom-class-serialization).
</Callout>


---
title: WORKFLOW_DESERIALIZE
---

# WORKFLOW_DESERIALIZE



A symbol used to define custom deserialization for user-defined class instances. The static method should accept serialized data and return a new class instance.

## Usage

```typescript lineNumbers
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";

class Point {
  constructor(public x: number, public y: number) {}

  static [WORKFLOW_SERIALIZE](instance: Point) {
    return { x: instance.x, y: instance.y };
  }

  static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) {
    return new Point(data.x, data.y);
  }
}
```

## API Signature

{/* @skip-typecheck: type-only signature snippet, not compilable code */}

```typescript
static [WORKFLOW_DESERIALIZE](data: SerializableData): T
```

### Parameters

<TSDoc
  definition={`
interface Parameters {
/**
 * The serialized data to reconstruct into a class instance.
 * This is the same data that was returned by WORKFLOW_SERIALIZE.
 */
data: SerializableData;
}
export default Parameters;`}
/>

### Returns

The method should return a new instance of the class, reconstructed from the serialized data.

## Requirements

<Callout type="warn">
  The method must be implemented as a **static** method on the class. Instance methods are not supported.
</Callout>

* Both `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` must be implemented together
* The method receives the exact data that was returned by `WORKFLOW_SERIALIZE`
* If `WORKFLOW_SERIALIZE` returns complex types (like `Map` or `Date`), they will be properly deserialized before being passed to this method

<Callout type="warn">
  This method runs inside the workflow context and is subject to the same constraints as `"use workflow"` functions:

  * No Node.js-specific APIs (like `fs`, `path`, `crypto`, etc.)
  * No non-deterministic operations (like `Math.random()` or `Date.now()`)
  * No external network calls

  Keep this method simple and focused on reconstructing the instance from the provided data.
</Callout>


---
title: WORKFLOW_SERIALIZE
---

# WORKFLOW_SERIALIZE



A symbol used to define custom serialization for user-defined class instances. The static method should accept an instance and return serializable data.

## Usage

```typescript lineNumbers
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";

class Point {
  constructor(public x: number, public y: number) {}

  static [WORKFLOW_SERIALIZE](instance: Point) {
    return { x: instance.x, y: instance.y };
  }

  static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) {
    return new Point(data.x, data.y);
  }
}
```

## API Signature

{/* @skip-typecheck: type-only signature snippet, not compilable code */}

```typescript
static [WORKFLOW_SERIALIZE](instance: T): SerializableData
```

### Parameters

<TSDoc
  definition={`
interface Parameters {
/**
 * The class instance to serialize.
 */
instance: T;
}
export default Parameters;`}
/>

### Returns

The method should return serializable data. This can be:

* Primitives (`string`, `number`, `boolean`, `null`, `undefined`, `bigint`)
* Plain objects with serializable values
* Arrays of serializable values
* Built-in serializable types (`Date`, `Map`, `Set`, `RegExp`, `URL`, etc.)
* Other custom classes that implement serialization

## Requirements

<Callout type="warn">
  The method must be implemented as a **static** method on the class. Instance methods are not supported.
</Callout>

* Both `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` must be implemented together
* The returned data must itself be serializable
* The SWC compiler plugin automatically detects and registers classes that implement these symbols

<Callout type="warn">
  This method runs inside the workflow context and is subject to the same constraints as `"use workflow"` functions:

  * No Node.js-specific APIs (like `fs`, `path`, `crypto`, etc.)
  * No non-deterministic operations (like `Math.random()` or `Date.now()`)
  * No external network calls

  Keep this method simple and focused on extracting data from the instance.
</Callout>


---
title: workflow/sveltekit
description: SvelteKit integration for automatic workflow bundling via Vite.
type: overview
summary: Explore the SvelteKit integration for automatic workflow bundling and runtime support.
related:
  - /docs/getting-started/sveltekit
---

# workflow/sveltekit



SvelteKit integration for Workflow SDK that configures Vite to transform workflow code and build the workflow bundles.

## Functions

<Cards>
  <Card title="workflowPlugin()" href="/docs/api-reference/workflow-sveltekit/workflow-plugin">
    Vite plugin that transforms workflow code (`"use step"`/`"use workflow"` directives) in SvelteKit apps
  </Card>
</Cards>


---
title: workflowPlugin
description: Configure Vite to transform workflow directives in SvelteKit.
type: reference
summary: Add workflowPlugin to your Vite config to enable workflow directive transformation in SvelteKit apps.
prerequisites:
  - /docs/getting-started/sveltekit
---

# workflowPlugin



Returns the Vite plugins that transform workflow code (`"use step"`/`"use workflow"` directives) and build the workflow bundles in a SvelteKit app.

## Usage

To enable `"use step"` and `"use workflow"` directives while developing locally or deploying to production, add `workflowPlugin()` to the `plugins` array of your Vite config.

```typescript title="vite.config.ts" lineNumbers
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import { workflowPlugin } from "workflow/sveltekit"; // [!code highlight]

export default defineConfig({
  plugins: [sveltekit(), workflowPlugin()], // [!code highlight]
});
```

## API Signature

### Parameters

This function does not accept any parameters in workflow 4.x. (5.x adds an options object with a `sourcemap` setting.)

### Returns

Returns an array of Vite `Plugin` objects. Spread or pass the array directly to the `plugins` option of your Vite config — Vite flattens nested plugin arrays automatically.


---
title: workflow/vite
description: Vite plugin for automatic workflow bundling in Vite + Nitro apps.
type: overview
summary: Explore the Vite plugin for automatic workflow bundling and runtime support.
related:
  - /docs/getting-started/vite
---

# workflow/vite



Vite integration for Workflow SDK. It wraps the [`workflow/nitro`](/docs/api-reference/workflow-nitro) module as a Vite plugin, for apps using Nitro's Vite plugin (`nitro/vite`).

## Functions

<Cards>
  <Card title="workflow()" href="/docs/api-reference/workflow-vite/workflow">
    Vite plugin that transforms workflow code (`"use step"`/`"use workflow"` directives) and configures the Nitro server
  </Card>
</Cards>


---
title: workflow
description: Configure Vite and Nitro to transform workflow directives.
type: reference
summary: Add the workflow plugin to your Vite config to enable workflow directive transformation in Vite + Nitro apps.
prerequisites:
  - /docs/getting-started/vite
---

# workflow



Returns the Vite plugins that transform workflow code (`"use step"`/`"use workflow"` directives) and configure the [`workflow/nitro`](/docs/api-reference/workflow-nitro) module on the Nitro server. It is designed to be used alongside `nitro()` from `nitro/vite`, which provides the server framework for API routes and deployment.

## Usage

To enable `"use step"` and `"use workflow"` directives while developing locally or deploying to production, add `workflow()` to the `plugins` array of your Vite config, together with `nitro()`.

```typescript title="vite.config.ts" lineNumbers
import { nitro } from "nitro/vite";
import { defineConfig } from "vite";
import { workflow } from "workflow/vite"; // [!code highlight]

export default defineConfig({
  plugins: [nitro(), workflow()], // [!code highlight]
  nitro: {
    serverDir: "./",
  },
});
```

## API Signature

### Parameters

| Parameter | Type            | Description                                                               |
| --------- | --------------- | ------------------------------------------------------------------------- |
| `options` | `ModuleOptions` | Optional. Forwarded to the `workflow/nitro` module as its module options. |

#### ModuleOptions

| Option             | Type       | Default | Description                                                                                                                                            |
| ------------------ | ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `dirs`             | `string[]` | —       | Directories to scan for workflows and steps. By default, the `workflows/` directory is scanned from the project root and all layer source directories. |
| `typescriptPlugin` | `boolean`  | `false` | Adds the `workflow` TypeScript plugin to the generated `tsconfig.json` for IDE IntelliSense.                                                           |
| `runtime`          | `string`   | —       | Node.js runtime version for Vercel Functions (e.g. `'nodejs22.x'`, `'nodejs24.x'`). Only applies when deploying to Vercel.                             |

### Returns

Returns an array of Vite `Plugin` objects. Spread or pass the array directly to the `plugins` option of your Vite config — Vite flattens nested plugin arrays automatically.


---
title: Child Workflows
description: Spawn child workflows from a parent and wait for completion via hook resume.
type: guide
summary: Orchestrate independent child workflows from a parent using start(), defineHook(), and startAndWait() — the child resumes the parent's hook when done instead of polling getRun().status.
related:
  - /docs/api-reference/workflow-api/start
---

# Child Workflows



Use child workflows when a single workflow needs to orchestrate many independent units of work. Each child runs as its own workflow with a separate event log, retry boundary, and failure scope -- if one child fails, it doesn't take down the parent or siblings.

## When to use child workflows

Child workflows are the right choice when:

* **Work units are independent.** Each child can run without knowing about the others (e.g., processing individual documents, generating separate reports).
* **You need isolated failure boundaries.** A failing child should not abort unrelated work. The parent decides how to handle failures.
* **You want massive fan-out.** Spawning 50 or 500 children is practical because each runs on its own infrastructure.
* **You need per-item observability.** Each child workflow has its own run ID, status, and event log for monitoring.

For simpler cases where steps share a single event log, use [direct await composition](/cookbook/common-patterns/workflow-composition#direct-await-flattening) instead.

## Basic pattern: spawn and wait via hook

The recommended pattern has four parts:

1. A **completion hook** the parent creates and awaits — zero compute while waiting
2. A **wrapped child export** that runs the real child in try/catch/finally and resumes the parent's hook from a step in `finally`
3. A **spawn step** that calls `start()` with the wrapped child and the hook token
4. A **`startAndWait()` helper** that ties the hook, spawn, and typed result together

```typescript
import { defineHook, getWorkflowMetadata } from "workflow";
import { start } from "workflow/api";
import { z } from "zod";

declare function fetchDocument(documentId: string): Promise<string>; // @setup
declare function analyzeContent(content: string): Promise<string>; // @setup
declare function generateSummary(analysis: string): Promise<string>; // @setup

const childCompletionHook = defineHook({
  schema: z.discriminatedUnion("status", [
    z.object({ status: z.literal("completed"), value: z.unknown() }),
    z.object({ status: z.literal("failed"), error: z.string() }),
  ]),
});

function completionToken(parentRunId: string, key: string) {
  return `child-completion:${parentRunId}:${key}`;
}

async function resumeParentCompletion(
  token: string,
  result:
    | { status: "completed"; value: unknown }
    | { status: "failed"; error: string }
) {
  "use step";
  await childCompletionHook.resume(token, result);
}

async function withChildCompletionHook<TResult>(
  runChild: () => Promise<TResult>,
  completionTokenArg: string
) {
  let result:
    | { status: "completed"; value: TResult }
    | { status: "failed"; error: string }
    | undefined;

  try {
    const value = await runChild();
    result = { status: "completed", value };
  } catch (error) {
    result = {
      status: "failed",
      error: error instanceof Error ? error.message : String(error),
    };
  } finally {
    if (result) {
      await resumeParentCompletion(completionTokenArg, result);
    }
  }
}

// Child workflow -- processes a single document
export async function processDocument(documentId: string) {
  "use workflow";

  const content = await fetchDocument(documentId);
  const analysis = await analyzeContent(content);
  const summary = await generateSummary(analysis);

  return { documentId, summary };
}

// Spawnable wrapper -- explicit export so `start()` can register it
export async function processDocumentWithCompletion(
  documentId: string,
  completionTokenArg: string
) {
  "use workflow";

  await withChildCompletionHook(
    () => processDocument(documentId),
    completionTokenArg
  );
}

async function spawnProcessDocument(
  documentId: string,
  completionTokenArg: string
): Promise<string> {
  "use step"; // [!code highlight]

  const run = await start(processDocumentWithCompletion, [
    documentId,
    completionTokenArg,
  ]); // [!code highlight]
  return run.runId;
}

async function startAndWait<TResult>(
  key: string,
  startChild: (completionTokenArg: string) => Promise<void>
): Promise<TResult> {
  const { workflowRunId } = getWorkflowMetadata();
  const token = completionToken(workflowRunId, key);
  const hook = childCompletionHook.create({ token }); // [!code highlight]

  await startChild(token);

  const completion = await hook; // [!code highlight]
  if (completion.status === "failed") {
    throw new Error(completion.error);
  }
  return completion.value as TResult;
}

// Parent workflow -- orchestrates document processing
export async function processDocumentBatch(documentIds: string[]) {
  "use workflow";

  const results = await Promise.all(
    documentIds.map((documentId) =>
      startAndWait<{ documentId: string; summary: string }>(documentId, (token) =>
        spawnProcessDocument(documentId, token).then(() => undefined)
      )
    )
  );

  return { processed: results.length, results };
}
```

### Why hooks instead of polling?

Polling with `getRun().status` in a `sleep()` loop works, but hook resume is preferable because:

* **Zero compute while waiting** — the parent suspends on the hook instead of waking every poll interval
* **Immediate wake-up** — the parent resumes as soon as the child finishes, not on the next poll tick
* **Typed payloads** — the child sends `{ status, value | error }` directly; no separate `returnValue` fetch step
* **No worker-pool pressure** — `Run#returnValue` polling inside steps can hold worker slots while waiting for children (see [Eager Processing](/v5/docs/changelog/eager-processing))

When a parent calls a child workflow inline with `await` (flattened into the same run), the same wrapper and hook handshake still works — pass the token and `await processDocumentWithCompletion(...)` inside `startAndWait()` instead of calling `start()`.

## Fan-out pattern: chunked spawning

When spawning hundreds of children, batch the `start()` calls to avoid overwhelming the system. Each child still gets its own completion hook keyed by a stable identifier (document ID, report ID, index).

```typescript
import { start } from "workflow/api";

declare function startAndWait<TResult>(
  key: string,
  startChild: (completionTokenArg: string) => Promise<void>
): Promise<TResult>; // @setup

const CHUNK_SIZE = 10;

export async function largeReportBatch(
  reportConfigs: Array<{ id: string; query: string }>
) {
  "use workflow";

  const results = [];
  for (let i = 0; i < reportConfigs.length; i += CHUNK_SIZE) {
    const chunk = reportConfigs.slice(i, i + CHUNK_SIZE);
    const chunkResults = await Promise.all(
      chunk.map((config) =>
        startAndWait<{ reportId: string; formatted: string }>(config.id, (token) =>
          spawnReportWithCompletion(config.id, config.query, token).then(
            () => undefined
          )
        )
      )
    );
    results.push(...chunkResults);
  }

  return { total: results.length, results };
}

async function spawnReportWithCompletion(
  reportId: string,
  query: string,
  completionTokenArg: string
): Promise<string> {
  "use step";

  const run = await start(generateReportWithCompletion, [
    reportId,
    query,
    completionTokenArg,
  ]);
  return run.runId;
}

async function generateReportWithCompletion(
  reportId: string,
  query: string,
  completionTokenArg: string
) {
  "use workflow";

  await withChildCompletionHook(
    () => generateReport(reportId, query),
    completionTokenArg
  );
}

async function generateReport(reportId: string, query: string) {
  "use workflow";

  const data = await queryDatabase(reportId, query);
  const formatted = await formatReport(reportId, data);
  return { reportId, formatted };
}

declare function queryDatabase(reportId: string, query: string): Promise<string>; // @setup
declare function formatReport(reportId: string, data: string): Promise<string>; // @setup
declare function withChildCompletionHook<TResult>(
  runChild: () => Promise<TResult>,
  completionTokenArg: string
): Promise<void>; // @setup
```

## Error handling

### Tolerating partial failures

Use `Promise.allSettled` with `startAndWait()` so one failing child doesn't abort siblings. The hook payload already carries `{ status: "failed", error }` — no status polling required.

```typescript
declare function startAndWait<TResult>(
  key: string,
  startChild: (completionTokenArg: string) => Promise<void>
): Promise<TResult>; // @setup
declare function spawnProcessDocument(
  documentId: string,
  completionTokenArg: string
): Promise<string>; // @setup

export async function processDocumentBatchTolerant(documentIds: string[]) {
  "use workflow";

  const settled = await Promise.allSettled(
    documentIds.map((documentId) =>
      startAndWait<{ documentId: string; summary: string }>(documentId, (token) =>
        spawnProcessDocument(documentId, token).then(() => undefined)
      )
    )
  );

  const results = settled
    .filter(
      (entry): entry is PromiseFulfilledResult<{ documentId: string; summary: string }> =>
        entry.status === "fulfilled"
    )
    .map((entry) => entry.value);

  const failed = settled.filter((entry) => entry.status === "rejected").length;

  return { processed: results.length, failed, results };
}
```

### Retrying failed children

When a child fails, spawn a replacement with a fresh hook token. Track restart counts to prevent infinite retry loops.

```typescript
declare function startAndWait<TResult>(
  key: string,
  startChild: (completionTokenArg: string) => Promise<void>
): Promise<TResult>; // @setup
declare function spawnProcessDocument(
  documentId: string,
  completionTokenArg: string
): Promise<string>; // @setup

async function startAndWaitWithRetries(
  documentId: string,
  maxRestarts: number
): Promise<{ documentId: string; summary: string }> {
  for (let attempt = 0; attempt <= maxRestarts; attempt++) {
    try {
      return await startAndWait<{ documentId: string; summary: string }>(
        `${documentId}:${attempt}`,
        (token) => spawnProcessDocument(documentId, token).then(() => undefined)
      );
    } catch (error) {
      if (attempt === maxRestarts) throw error;
    }
  }

  throw new Error("unreachable");
}
```

## Tips

* **`start()` must be called from a step** in v4, not directly from a workflow function. Bake the wrapped workflow reference into the step — don't pass workflow functions as step arguments.
* **`defineHook().resume()` must be called from a step.** The wrapped child's `finally` block calls a step that resumes the parent hook.
* **Export wrapped children at module scope.** The SDK registers `"use workflow"` functions statically — a runtime higher-order function returned from `withChildCompletionHook()` cannot be passed to `start()`.
* **Use stable hook keys** — document ID, job ID, or index — so parallel children inside one parent run don't collide on tokens.
* **Use chunked spawning for large batches.** Spawning 500 children in a single step can time out. Break it into chunks of 10-50.
* **Each child has its own retry semantics.** Steps inside child workflows retry independently. The parent sees the final `{ status, value | error }` payload from the hook.
* **Use `deploymentId: "latest"`** if children should run on the most recent deployment. See [Versioning](/docs/foundations/versioning) for the full model and the [`start()` API reference](/docs/api-reference/workflow-api/start#using-deploymentid-latest) for compatibility considerations.

## Key APIs

* [`start()`](/docs/api-reference/workflow-api/start) -- spawn a new workflow run and get its run ID
* [`defineHook()`](/docs/api-reference/workflow/define-hook) -- typed hook for parent/child completion handshakes
* [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) -- resume a waiting parent from a step (called by the child wrapper)
* [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) -- read the parent run ID for deterministic hook tokens
* [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
* [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions with full Node.js access


---
title: Distributed Abort Controller
description: A distributed AbortController that uses durable workflows for cross-process cancellation signaling.
type: guide
summary: Build a distributed abort controller that uses workflow streams and hooks to propagate cancellation signals across process boundaries.
---

# Distributed Abort Controller



Use this pattern when you need an `AbortController`-like interface that works across distributed systems. The controller uses a durable workflow to coordinate cancellation — calling `.abort()` on one machine triggers the `.signal` on any other machine.

## When to use this

* **Cross-process cancellation** — Cancel a long-running operation from a different server, worker, or edge function
* **Durable cancellation** — The abort signal persists even if the process that created it crashes
* **UI stop buttons** — Let users cancel operations running on the server from the browser
* **Timeout coordination** — The built-in TTL auto-expires stale controllers

## Pattern

The `DistributedAbortController` class encapsulates a workflow that:

1. Accepts a user-provided unique ID (like a chat ID or task ID)
2. Creates or reconnects to an existing workflow using that ID
3. Waits for a hook signal OR TTL expiration
4. Writes a cancellation message to the run's stream when triggered

### Core Implementation

```typescript lineNumbers
import { defineHook, getWritable, sleep } from "workflow";
import { start, getRun, getHookByToken } from "workflow/api";

// Default TTL: 24 hours
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
// Default grace period: 1 hour (keeps hook alive after abort for late subscribers)
const DEFAULT_GRACE_MS = 60 * 60 * 1000;

// Hook to trigger the abort signal
export const abortHook = defineHook<{ reason?: string }>();

// The abort message written to the stream
export type AbortMessage = {
  type: "abort";
  reason?: string;
  expired?: boolean;
};

// Helper to create a consistent hook token from the user ID
function getAbortToken(id: string): string {
  return `abort:${id}`;
}

// Step function that writes the abort message to the stream
async function writeAbortSignal(reason?: string, expired?: boolean) {
  "use step";

  const writable = getWritable<AbortMessage>();
  const writer = writable.getWriter();
  try {
    await writer.write({ type: "abort", reason, expired });
  } finally {
    writer.releaseLock();
  }
  await writable.close();
}

// Workflow that waits for abort or TTL expiration
export async function abortControllerWorkflow(
  id: string,
  ttlMs: number,
  graceMs: number
) {
  "use workflow";

  const startTime = Date.now();
  const hook = abortHook.create({ token: getAbortToken(id) });

  // Race: manual abort OR TTL expiration // [!code highlight]
  const result = await Promise.race([
    hook.then((payload) => ({
      reason: payload.reason,
      expired: false,
    })),
    sleep(`${ttlMs}ms`).then(() => ({
      reason: "Controller expired",
      expired: true,
    })),
  ]);

  await writeAbortSignal(result.reason, result.expired);

  // Only sleep through grace period on TTL expiration (keeps hook alive for late subscribers). // [!code highlight]
  // Manual aborts complete immediately.
  if (result.expired) {
    const elapsed = Date.now() - startTime;
    const remainingTime = graceMs - (elapsed - ttlMs);
    if (remainingTime > 0) {
      await sleep(`${remainingTime}ms`); // [!code highlight]
    }
  }

  return { aborted: true, reason: result.reason, expired: result.expired };
}

/**
 * A distributed abort controller that works across process boundaries.
 * Uses a semantically meaningful ID (like a chat ID or task ID) to coordinate.
 */
export class DistributedAbortController {
  private id: string;
  readonly runId: string;

  private constructor(id: string, runId: string) {
    this.id = id;
    this.runId = runId;
  }

  /**
   * Creates or reconnects to a distributed abort controller.
   * If a controller with this ID already exists, reconnects to it.
   * Otherwise, starts a new workflow.
   *
   * @param id - A unique, semantically meaningful ID (e.g., "chat:123")
   * @param options.ttlMs - Time-to-live in ms (default: 24 hours)
   * @param options.graceMs - Grace period after abort (default: 1 hour)
   */
  static async create( // [!code highlight]
    id: string,
    options: { ttlMs?: number; graceMs?: number } = {}
  ): Promise<DistributedAbortController> {
    const { ttlMs = DEFAULT_TTL_MS, graceMs = DEFAULT_GRACE_MS } = options;
    const token = getAbortToken(id);

    // Try to find an existing run with this hook token
    const existingHook = await getHookByToken(token).catch(() => null); // [!code highlight]

    if (existingHook) {
      // Reconnect to existing controller
      return new DistributedAbortController(id, existingHook.runId);
    }

    // Create a new workflow
    const run = await start(abortControllerWorkflow, [id, ttlMs, graceMs]); // [!code highlight]
    return new DistributedAbortController(id, run.runId);
  }

  /**
   * Triggers the abort signal.
   * Idempotent: safe to call multiple times or after the workflow has completed.
   */
  async abort(reason?: string): Promise<void> { // [!code highlight]
    try {
      await abortHook.resume(getAbortToken(this.id), { reason });
    } catch (error) {
      const msg = error instanceof Error ? error.message.toLowerCase() : '';
      if (msg.includes('not found') || msg.includes('expired')) {
        return;
      }
      throw error;
    }
  }

  /**
   * Returns an AbortSignal that fires when abort() is called or TTL expires.
   * The signal fires with a reason indicating what triggered it.
   */
  get signal(): AbortSignal { // [!code highlight]
    const run = getRun<{ aborted: boolean; reason?: string; expired?: boolean }>(this.runId);
    const controller = new AbortController();
    const readable = run.getReadable<AbortMessage>();

    (async () => {
      const reader = readable.getReader();
      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          if (value.type === "abort") {
            const reason = value.expired
              ? `${value.reason} (expired)`
              : value.reason;
            controller.abort(reason);
            break;
          }
        }
      } catch (error) {
        if (!controller.signal.aborted) {
          controller.abort(
            error instanceof Error ? error.message : "Stream read failed"
          );
        }
      } finally {
        reader.releaseLock();
      }
    })();

    return controller.signal;
  }
}
```

### Usage: Single Process

```typescript lineNumbers
import { DistributedAbortController } from "./distributed-abort-controller";

// Create a controller with a meaningful ID
const controller = await DistributedAbortController.create("chat:user-123");

// Get the signal and use it with fetch
const signal = controller.signal;
const response = await fetch("https://api.example.com/long-operation", {
  signal,
});

// Later: abort the operation
await controller.abort("User cancelled");
```

### Usage: Cross-Process Coordination

```typescript lineNumbers
import { DistributedAbortController } from "./distributed-abort-controller";

// Process A: Create the controller
const controller = await DistributedAbortController.create("task:build-123");
// start long operation using controller.signal...

// Process B: Reconnect and abort (no run ID sharing needed!)
const sameController = await DistributedAbortController.create("task:build-123"); // [!code highlight]
await sameController.abort("Cancelled by admin");

// Process C: Reconnect and listen
const anotherRef = await DistributedAbortController.create("task:build-123");
anotherRef.signal.addEventListener("abort", (e) => {
  console.log("Task was cancelled:", (e.target as AbortSignal).reason);
});
```

### Custom TTL

```typescript lineNumbers
import { DistributedAbortController } from "./distributed-abort-controller";

// Short-lived controller for a quick operation (5 minutes)
const shortLived = await DistributedAbortController.create("quick-task", {
  ttlMs: 5 * 60 * 1000,
});

// Long-lived controller for batch jobs (7 days)
const longLived = await DistributedAbortController.create("batch-job", {
  ttlMs: 7 * 24 * 60 * 60 * 1000,
});

// When TTL expires, the signal fires with expired reason
shortLived.signal.addEventListener("abort", (e) => {
  const reason = (e.target as AbortSignal).reason;
  if (reason?.includes("expired")) {
    console.log("Controller expired, cleaning up...");
  }
});
```

### API Route for Remote Abort

```typescript lineNumbers
import { DistributedAbortController } from "@/lib/distributed-abort-controller";

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

  const controller = await DistributedAbortController.create(id);
  await controller.abort(reason || "Cancelled via API");

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

### Client Cancel Button

```tsx lineNumbers
"use client";

export function CancelButton({ taskId }: { taskId: string }) {
  const handleCancel = async () => {
    await fetch(`/api/abort/${taskId}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ reason: "User clicked cancel" }),
    });
  };

  return (
    <button type="button" onClick={handleCancel}>
      Cancel Operation
    </button>
  );
}
```

## Tips

* **Use semantic IDs** — Use meaningful IDs like `chat:123` or `task:abc` instead of random UUIDs
* **Create is idempotent** — Calling `create()` with the same ID reconnects to the existing controller
* **TTL auto-cleanup** — Workflows self-terminate after TTL expires; no manual cleanup needed
* **Signal is a getter** — Each access to `.signal` creates a new listener; cache it if needed
* **One-shot** — Once aborted or expired, the workflow completes; create a new controller for new operations

## Key APIs

* [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook for the abort trigger
* [`getWritable()`](/docs/api-reference/workflow/get-writable) — write abort messages to the stream
* [`sleep()`](/docs/api-reference/workflow/sleep) — TTL timer for auto-expiration
* [`start()`](/docs/api-reference/workflow-api/start) — start the abort controller workflow
* [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) — find existing run by hook token
* [`getRun()`](/docs/api-reference/workflow-api/get-run) — reconnect to the workflow's readable stream


---
title: Publishing Libraries
description: Structure and publish npm packages that export workflow functions for consumers to use with Workflow SDK.
type: guide
summary: Learn how to build, export, and test npm packages that ship workflow and step functions — including package.json exports, re-exporting for stable workflow IDs, keeping step I/O clean, and integration testing.
---

# Publishing Libraries



import { File, Folder, Files } from "fumadocs-ui/components/files";

<Callout>
  This is an advanced guide for library authors who want to publish reusable workflow functions as npm packages. It assumes familiarity with `"use workflow"`, `"use step"`, and the workflow execution model.
</Callout>

## Package Structure

A workflow library follows a standard TypeScript package layout with a dedicated `workflows/` directory. Each workflow file exports one or more workflow functions that consumers can import and pass to `start()`.

<Files>
  <Folder name="my-media-lib" defaultOpen>
    <Folder name="src" defaultOpen>
      <File name="index.ts" />

      <File name="types.ts" />

      <Folder name="workflows" defaultOpen>
        <File name="index.ts" />

        <File name="transcode.ts" />

        <File name="generate-thumbnails.ts" />
      </Folder>

      <Folder name="lib" defaultOpen>
        <File name="api-client.ts" />
      </Folder>
    </Folder>

    <Folder name="test-server" defaultOpen>
      <File name="workflows.ts" />
    </Folder>

    <File name="tsup.config.ts" />

    <File name="package.json" />

    <File name="tsconfig.json" />
  </Folder>
</Files>

Key files:

* **`src/index.ts`** — Package entry point. Exports the public API.
* **`src/types.ts`** — Shared TypeScript types.
* **`src/workflows/index.ts`** — Re-exports every workflow so consumers can pull them in under one specifier (see [Entry Points and Exports](#entry-points-and-exports)).
* **`src/workflows/*.ts`** — One file per workflow function (e.g. `transcode.ts`, `generate-thumbnails.ts`).
* **`src/lib/`** — Internal helpers. Plain async code, *not* marked with `"use workflow"` or `"use step"`.
* **`test-server/workflows.ts`** — Re-export file used by integration tests (see [Testing Workflow Libraries](#testing-workflow-libraries)).

### Entry Points and Exports

Use the `exports` field in `package.json` to expose separate entry points for the main API and the raw workflow functions:

```json
{
  "name": "@acme/media",
  "type": "module",
  "exports": {
    ".": {
      "types": { "import": "./dist/index.d.ts" },
      "import": "./dist/index.js"
    },
    "./workflows": {
      "types": { "import": "./dist/workflows/index.d.ts" },
      "import": "./dist/workflows/index.js"
    }
  },
  "files": ["dist"]
}
```

The main entry point (`@acme/media`) exports types, utilities, and convenience wrappers. The `./workflows` entry point (`@acme/media/workflows`) exports the raw workflow functions that consumers need for the build system.

### Source Files

The package entry re-exports workflows alongside any utilities:

```typescript lineNumbers
// src/index.ts
export * from "./types";
export * as workflows from "./workflows";
```

The workflows barrel file re-exports each workflow:

```typescript lineNumbers
// src/workflows/index.ts
export * from "./transcode";
export * from "./generate-thumbnails";
```

### Build Configuration

Use a bundler like `tsup` with separate entry points for each export. Mark `workflow` as external so it's resolved from the consumer's project:

```typescript lineNumbers
// tsup.config.ts
import { defineConfig } from "tsup";

export default defineConfig({
  entry: [
    "src/index.ts",
    "src/workflows/index.ts",
  ],
  format: ["esm"],
  dts: true,
  sourcemap: true,
  clean: true,
  external: ["workflow"], // [!code highlight]
});
```

## Re-Exporting for Workflow ID Stability

Workflow SDK's compiler assigns each workflow function a stable ID based on its position in the source file that the build system processes. When a consumer imports a pre-built workflow from an npm package, the compiler never sees the original source — it only sees the compiled output. This means workflow IDs won't match between the library's development environment and the consumer's app.

The fix is a **re-export file**. The consumer creates a file in their `workflows/` directory that re-exports the library's workflows. The build system then processes this file and assigns stable IDs.

### Consumer Setup

```typescript lineNumbers
// workflows/media.ts (in the consumer's project)
// Re-export library workflows so the build system assigns stable IDs
export * from "@acme/media/workflows"; // [!code highlight]
```

This one-line file is all that's needed. The workflow compiler transforms this file, discovers the workflow and step functions from the library, and assigns IDs that are stable across deployments.

### Why This Is Necessary

Without re-exporting, the workflow runtime cannot match a running workflow to its function definition. When a workflow run is replayed after a cold start, the runtime looks up functions by their compiler-assigned IDs. If the IDs don't exist (because the compiler never processed the library's source), replay fails.

The re-export pattern ensures:

1. **Stable IDs** — the compiler assigns IDs based on the consumer's source tree
2. **Replay safety** — IDs persist across deployments and cold starts
3. **Version upgrades** — re-exported IDs remain stable as long as the consumer's file doesn't change

## Keeping Step I/O Clean

When you publish a workflow library, every step function's inputs and outputs are recorded in the event log. This has two implications:

### 1. Everything Must Be Serializable

Step inputs and outputs must be serializable. The workflow runtime supports a rich set of types beyond plain JSON — including `Date`, `RegExp`, `Map`, `Set`, `BigInt`, `Uint8Array`, `URL`, `Error`, and class instances that implement [custom class serialization](/docs/foundations/serialization#custom-class-serialization). See the [serialization reference](/docs/foundations/serialization) for the full list of supported types. Do not pass or return:

* Functions or closures
* `WeakRef`, `WeakMap`, or `WeakSet`

If your library works with complex objects that don't implement custom class serialization, pass serializable configuration into steps and reconstruct the objects inside the step body.

{/* @skip-typecheck - good/bad comparison with duplicate function names */}

```typescript lineNumbers
// Good: pass serializable config, construct inside the step
async function callExternalApi(endpoint: string, params: Record<string, string>) {
  "use step";
  const client = createApiClient(process.env.API_KEY!);
  return await client.request(endpoint, params);
}

// Bad: pass a pre-constructed client object
async function callExternalApi(client: ApiClient, params: Record<string, string>) {
  "use step";
  // ApiClient is not serializable — this will fail on replay
  return await client.request(params);
}
```

See [Serializable Steps](/cookbook/advanced/serializable-steps) for the step-as-factory pattern.

### 2. Credentials

With workflow encryption enabled, credentials passed as step arguments are encrypted in the event log, so either approach is valid:

{/* @skip-typecheck - good/bad comparison with duplicate function names */}

```typescript lineNumbers
// Option A: resolve credentials from environment inside the step
async function fetchData(query: string) {
  "use step";
  const client = createClient(process.env.API_KEY!);
  return await client.fetch(query);
}

// Option B: pass credentials as step arguments (encrypted in the event log)
async function fetchData(apiKey: string, query: string) {
  "use step";
  const client = createClient(apiKey);
  return await client.fetch(query);
}
```

The choice is a matter of library API design preference. Resolving from environment variables keeps the step signature simpler, while passing credentials explicitly makes dependencies visible and can be easier to test.

## Testing Workflow Libraries

Library authors need integration tests that exercise workflows through the full Workflow SDK runtime — not just unit tests of individual functions.

### Test Server Pattern

Create a minimal test server that re-exports your library's workflows, just like a consumer would:

```typescript lineNumbers
// test-server/workflows.ts
export * from "@acme/media/workflows"; // [!code highlight]
```

This test server acts as a stand-in consumer app. Point your test runner at it to exercise the full workflow lifecycle: start, replay, and completion.

### Vitest Configuration

Use a dedicated Vitest config for integration tests that run against the Workflow SDK runtime:

```typescript lineNumbers
// vitest.workflowsdk.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["tests/integration/**/*.workflowsdk.test.ts"],
    testTimeout: 120_000, // Workflows may take time to complete
    setupFiles: ["./tests/setup.ts"],
  },
});
```

Run these tests separately from your unit tests:

```bash
# Unit tests (fast, no workflow runtime)
pnpm vitest run tests/unit

# Integration tests (requires workflow runtime)
pnpm vitest run --config vitest.workflowsdk.config.ts
```

### What to Test

* **Happy path**: workflow starts, all steps execute, and the final result is correct
* **Serialization round-trip**: inputs and outputs survive the event log
* **Replay**: kill and restart a workflow mid-execution to verify deterministic replay
* **Error handling**: verify that step failures produce the expected errors

## Working With and Without Workflow Installed

Some libraries want to be useful to consumers who *aren't* using Workflow SDK at all — the library picks up durable behavior when a workflow runtime is present and falls back to plain async execution otherwise.

<Callout type="info">
  Two rules for isomorphic packages:

  1. **Any runtime reference to the `workflow` package must be loaded via dynamic `import("workflow")` inside a try/catch.** A static top-level import makes the module fail to load for consumers who haven't installed workflow.
  2. **The `"use workflow"` and `"use step"` directives are safe to keep in your library source.** When a consumer compiles your code with the Workflow SDK toolchain (via the [re-export pattern](#re-exporting-for-workflow-id-stability) above), the SWC plugin transforms them into durable-execution glue. When they're not compiled — plain Node, plain tests, a consumer without the runtime — they are just string expression statements and run as no-ops.
</Callout>

### Optional peer dependency

Declare `workflow` as an **optional** peer so consumers without the runtime aren't forced to install it:

```json
{
  "peerDependencies": {
    "workflow": ">=4.0.0"
  },
  "peerDependenciesMeta": {
    "workflow": {
      "optional": true
    }
  }
}
```

### Runtime detection

Wrap a dynamic `import("workflow")` in try/catch. If either the module isn't installed *or* `getStepMetadata()` throws (call site isn't inside a workflow step), fall through to the standalone path.

```typescript lineNumbers
async function getWorkflowStepId(): Promise<string | null> { // [!code highlight]
  try {
    const wf = await import("workflow");
    const { stepId } = wf.getStepMetadata();
    return stepId;
  } catch {
    return null;
  }
}
```

### A concrete use case: replay-safe idempotency keys

A payments utility that uses the current workflow step ID as a Stripe idempotency key when available, and a fresh UUID otherwise:

```typescript lineNumbers
declare function getWorkflowStepId(): Promise<string | null>; // @setup (defined in the previous block)

export async function processPayment(amount: number, currency: string) {
  const stepId = await getWorkflowStepId();
  const idempotencyKey = stepId ? `payment:${stepId}` : crypto.randomUUID(); // [!code highlight]

  const res = await fetch("https://api.stripe.com/v1/charges", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
      "Idempotency-Key": idempotencyKey, // [!code highlight]
    },
    body: new URLSearchParams({ amount: String(amount), currency }),
  });
  return res.json();
}
```

When called from inside a workflow step, the utility gets a stable idempotency key for that step across retries — Stripe dedupes retries for free. When called from a plain Node.js process, it behaves like any other function and a fresh UUID is generated. For more patterns, see [Idempotency](/docs/foundations/idempotency).

### In production

Packages in the wild built on Workflow SDK:

* **[`@mux/ai`](https://github.com/muxinc/ai)** — Reusable video AI workflows (summaries, chapters, content moderation, translation, embeddings) exported with `"use workflow"` / `"use step"` directives. In a standard Node environment the directives are no-ops and the SDK runs as a plain async library; in a Workflow SDK environment the consumer's compiler transforms them into durable, resumable steps with automatic retries and observability. Written up in detail in [*How Mux shipped durable video workflows with their @mux/ai SDK*](https://vercel.com/blog/how-mux-shipped-durable-video-workflows-with-their-mux-ai-sdk) on the Vercel blog.
* **World ID** — Human-in-the-loop "proof of human" primitive for agent workflows. Developers drop a World ID step into any workflow to require a zero-knowledge cryptographic proof that a real, unique human authorized a specific action (deploy approvals, large payments, sensitive data access, etc.). Because it runs as a workflow step, every verification is durable, replay-safe, and viewable inside the run's execution timeline — giving you a provable audit record of which human approved what. Available on npm and announced in [*World ID for agents: Browserbase, Exa, Okta, and Vercel*](https://world.org/blog/announcements/browserbase-exa-okta-world-id-for-agentic-web) on the World blog.

## Checklist

Before publishing a workflow library:

* [ ] `workflow` is listed as an **optional** peer dependency
* [ ] Separate `./workflows` export in `package.json` for the raw workflow functions
* [ ] `workflow` is marked as **external** in your bundler config
* [ ] Documentation tells consumers to re-export from `@your-lib/workflows`
* [ ] Credentials are either resolved from environment variables or passed explicitly (both are safe with encryption enabled)
* [ ] All step I/O uses [supported serializable types](/docs/foundations/serialization)
* [ ] Integration tests use a test server with re-exported workflows
* [ ] Both with-workflow and without-workflow code paths are tested

## Key APIs

* [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) — declares the orchestrator function
* [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — marks functions for durable execution
* [`start`](/docs/api-reference/workflow-api/start) — starts a workflow run
* [`getWorkflowMetadata`](/docs/api-reference/workflow/get-workflow-metadata) — runtime detection and run ID access


---
title: Serializable Steps
description: Wrap non-serializable third-party objects (like AI model providers) inside step factory functions so they can cross the workflow boundary.
type: guide
summary: Return a callback from a step to defer construction of a non-owned class (AI SDK models, cloud SDK clients) until execution time, making them usable inside durable workflows.
related:
  - /docs/foundations/serialization
  - /docs/foundations/serialization#custom-class-serialization
  - /docs/foundations/workflows-and-steps#step-functions
---

# Serializable Steps



<Callout>
  This is an advanced guide. It dives into workflow internals and is not required reading to use workflow.
</Callout>

## When to use this pattern

Workflow functions run inside a sandboxed VM where every value that crosses a function boundary must be serializable. There are two ways to get a non-serializable object across that boundary, depending on whether you own the class:

* **You own the class** — implement the [`WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE` protocol](/docs/foundations/serialization#custom-class-serialization). The instance becomes a first-class serializable value: you can pass it as a workflow input, return it from a step, and call `"use step"` instance methods on it directly. This is the right tool when the class is yours to modify.
* **You don't own the class** — you can't add methods to `openai("gpt-4o")` from `@ai-sdk/openai` or `new S3Client({...})` from `@aws-sdk/client-s3`. Instead, wrap construction in a `"use step"` factory function and pass the factory across the boundary. That's what this page covers.

## The Problem

AI SDK model providers — `openai("gpt-4o")`, `anthropic("claude-sonnet-4-20250514")`, etc. — return complex objects with methods, closures, and internal state. Passing one directly into a step causes a serialization error, and you can't bolt `WORKFLOW_SERIALIZE` onto a third-party class.

```typescript lineNumbers
import { openai } from "@ai-sdk/openai";
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import type { UIMessageChunk } from "ai";

export async function brokenAgent(prompt: string) {
  "use workflow";

  const writable = getWritable<UIMessageChunk>();
  const agent = new DurableAgent({
    // This fails — the model object is not serializable
    model: openai("gpt-4o"),
  });

  await agent.stream({ messages: [{ role: "user", content: prompt }], writable });
}
```

## The Solution: Step-as-Factory

Instead of passing the model object, pass a **callback function** that returns the model. Marking that callback with `"use step"` tells the compiler to serialize the *function reference* (which is just a string identifier) rather than its return value. The provider is only instantiated at execution time, inside the step's full Node.js runtime.

```typescript lineNumbers
import { openai as openaiProvider } from "@ai-sdk/openai";

// Returns a step function, not a model object
export function openai(...args: Parameters<typeof openaiProvider>) {
  return async () => {
    "use step";
    return openaiProvider(...args); // [!code highlight]
  };
}
```

The `DurableAgent` receives a function (`() => Promise<LanguageModel>`) instead of a model object. When the agent needs to call the LLM, it invokes the factory inside a step where the real provider can be constructed with full Node.js access.

## How `@workflow/ai` Uses This

<Callout type="warn">
  `@workflow/ai`'s pre-wrapped providers and `DurableAgent` are deprecated. AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) resolves models from AI Gateway model strings (e.g. `"openai/gpt-4o"`), which usually removes the need for a model factory — see the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). The serialization pattern on this page still applies to any non-serializable dependency you own (for example, cloud SDK clients).
</Callout>

The `@workflow/ai` package ships pre-wrapped providers for all major AI SDK backends. Each one follows the same pattern:

```typescript lineNumbers
// packages/ai/src/providers/anthropic.ts
import { anthropic as anthropicProvider } from "@ai-sdk/anthropic";

export function anthropic(...args: Parameters<typeof anthropicProvider>) {
  return async () => {
    "use step";
    return anthropicProvider(...args); // [!code highlight]
  };
}
```

This means you import from `@workflow/ai` instead of `@ai-sdk/*` directly:

```typescript lineNumbers
import { anthropic } from "@workflow/ai/anthropic";
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import type { UIMessageChunk } from "ai";

export async function chatAgent(prompt: string) {
  "use workflow";

  const writable = getWritable<UIMessageChunk>();
  const agent = new DurableAgent({
    model: anthropic("claude-sonnet-4-20250514"), // [!code highlight]
  });

  await agent.stream({ messages: [{ role: "user", content: prompt }], writable });
}
```

## Writing Your Own Serializable Wrapper

Apply the same pattern to any non-serializable dependency. The key rule: **the outer function captures serializable arguments, and the inner `"use step"` function constructs the real object at runtime**.

```typescript lineNumbers
import type { S3Client as S3ClientType } from "@aws-sdk/client-s3";

// The arguments (region, bucket) are plain strings — serializable
export function createS3Client(region: string) {
  return async (): Promise<S3ClientType> => {
    "use step";
    const { S3Client } = await import("@aws-sdk/client-s3");
    return new S3Client({ region });
  };
}

// Usage in a workflow
export async function processUpload(region: string, key: string) {
  "use workflow";

  const getClient = createS3Client(region); // [!code highlight]
  // getClient is a serializable step reference, not an S3Client
  await uploadFile(getClient, key);
}

async function uploadFile(
  getClient: () => Promise<S3ClientType>,
  key: string
) {
  "use step";
  const client = await getClient(); // [!code highlight]
  // Now you have a real S3Client with full Node.js access
  await client.send(/* ... */);
}
```

## Why This Works

1. **Compiler transformation**: `"use step"` tells the SWC plugin to extract the function into a separate bundle. The workflow VM only sees a serializable reference (function ID + captured arguments).
2. **Closure tracking**: The compiler tracks which variables the step function closes over. Only serializable values (strings, numbers, plain objects) can be captured.
3. **Deferred construction**: The actual provider/client is only constructed when the step executes in the Node.js runtime — never in the sandboxed workflow VM.

## Key APIs

* [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — marks a function for extraction and serialization
* [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) — declares the orchestrator function
* [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) — AI SDK's durable agent (resolves models via AI Gateway strings; replaces `DurableAgent`)
* [Custom class serialization](/docs/foundations/serialization#custom-class-serialization) — the companion pattern for classes you own (`WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE`)


---
title: Upgrading Workflows
description: Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward.
type: guide
summary: Identify a clean upgrade point and hand off to a fresh run via `start(self, [state], { deploymentId: "latest" })` — either automatically on every iteration, or on demand via a dedicated upgrade hook.
related:
  - /docs/foundations/versioning
  - /cookbook/common-patterns/workflow-composition
  - /docs/api-reference/workflow-api/start
  - /docs/foundations/hooks
---

# Upgrading Workflows



Workflows that block on external events for days, weeks, or months can outlive many deployments. **The key is to identify a clean upgrade point in the workflow** — a moment where it's safe to checkpoint state and start fresh — and then call [`start()`](/docs/api-reference/workflow-api/start) with `deploymentId: "latest"` to spawn a new run carrying that state forward. The current run ends; the next run begins on whatever deployment is live at that moment, so shipped fixes apply immediately without ever migrating an in-flight run.

<Callout type="info">
  For the underlying model — why runs pin to a deployment by default, how cancel-and-rerun works, and how state crosses the version boundary — see [Versioning](/docs/foundations/versioning). This recipe focuses on event-driven workflows that need to keep advancing across deployments.
</Callout>

A clean upgrade point is any spot in the workflow where:

* All in-progress side effects have completed (or aren't needed by the next iteration)
* The relevant state can be serialized into the workflow's input arguments
* It's natural for the workflow to "checkpoint" — typically right after handling an external event, completing a batch, or finishing a logical phase

There are two ways to apply this:

1. **Upgrade on every iteration** ([Method 1](#method-1-upgrade-on-every-iteration)). Each run handles a single event and unconditionally hands off to a fresh run on the latest deployment before exiting. Simple — no extra triggers — but every event pays the respawn cost.
2. **Upgrade on demand via a dedicated hook** ([Method 2](#method-2-upgrade-on-demand-via-a-dedicated-hook)). A single long-lived run handles many events in a loop and only respawns when an `upgradeHook` fires. A separate endpoint resumes that hook from your control plane (e.g. after a deploy). More control and fewer respawns, at the cost of an explicit trigger.

### When to use each

* **Method 1** when iterations are short and frequent, the work is cheap to checkpoint, and you want shipped fixes to apply on the very next event. Long-lived "session" workflows (subscriptions, queues, FSMs) that already process events one at a time fit this naturally.
* **Method 2** when iterations are infrequent or expensive (you don't want to respawn on every event), or when you need to roll out a fix to a fleet of in-flight runs after a deploy by fanning out to a control-plane endpoint. Also fits when "upgrade" should be an explicit operation rather than a side effect of handling each event.

## Method 1: Upgrade on every iteration

Each run inherits state via its argument, blocks on a hook, processes the resume, then unconditionally hands off to its successor. The `start()` call is wrapped in a `"use step"` function (required) and passes `deploymentId: "latest"` so the new run lands on the freshest code.

```typescript lineNumbers
import { defineHook, getWorkflowMetadata } from "workflow";
import { start } from "workflow/api";

declare function processItem(itemId: string): Promise<void>; // @setup

interface QueueState {
  processed: number;
  cursor: string | null;
}

export const nextItemHook = defineHook<{ itemId: string }>();

async function spawnSelfOnLatest(state: QueueState): Promise<string> {
  "use step"; // [!code highlight]

  // `deploymentId: "latest"` resolves to whichever deployment is current
  // when this spawn lands — NOT the deployment running this code.
  const next = await start(longRunningQueue, [state], { // [!code highlight]
    deploymentId: "latest", // [!code highlight]
  }); // [!code highlight]
  return next.runId;
}

export async function longRunningQueue(
  state: QueueState = { processed: 0, cursor: null },
): Promise<void> {
  "use workflow";

  const { workflowRunId } = getWorkflowMetadata();

  // Block until something fires the hook — could be hours, days, or longer.
  // Per-run hook tokens (workflowRunId) keep concurrent chains isolated.
  const { itemId } = await nextItemHook.create({ token: workflowRunId }); // [!code highlight]

  await processItem(itemId);

  // Hand off to a fresh run on the latest deployment. THIS run ends here.
  await spawnSelfOnLatest({ // [!code highlight]
    processed: state.processed + 1, // [!code highlight]
    cursor: itemId, // [!code highlight]
  }); // [!code highlight]
}
```

### Resuming the hook

Any server-side code can resume the currently-active iteration by calling `.resume()` with the run ID:

```typescript
import { nextItemHook } from "@/workflows/long-running-queue";

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

  await nextItemHook.resume(runId, { itemId }); // [!code highlight]

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

The caller tracks the active `runId` (e.g. in a database, KV, or returned from the previous iteration) and updates it whenever the chain advances.

## Method 2: Upgrade on demand via a dedicated hook

Use a single long-running workflow that handles events in a loop. Define a second hook — `upgradeHook` — alongside the work hook, and race them. While only the work hook fires, the run keeps handling events on its current deployment. When `upgradeHook` resumes, the workflow captures current state and respawns on the latest deployment, then exits.

```typescript lineNumbers
import { defineHook, getWorkflowMetadata } from "workflow";
import { start } from "workflow/api";

declare function processItem(itemId: string): Promise<void>; // @setup

interface QueueState {
  processed: number;
  cursor: string | null;
}

export const nextItemHook = defineHook<{ itemId: string }>();
export const upgradeHook = defineHook<{ reason?: string }>(); // [!code highlight]

async function spawnSelfOnLatest(state: QueueState): Promise<string> {
  "use step";

  const next = await start(longRunningQueue, [state], {
    deploymentId: "latest",
  });
  return next.runId;
}

export async function longRunningQueue(
  state: QueueState = { processed: 0, cursor: null },
): Promise<void> {
  "use workflow";

  const { workflowRunId } = getWorkflowMetadata();

  while (true) {
    // Race a normal work event against the upgrade signal.
    const event = await Promise.race([ // [!code highlight]
      nextItemHook
        .create({ token: workflowRunId })
        .then((payload) => ({ kind: "work" as const, payload })),
      upgradeHook // [!code highlight]
        .create({ token: workflowRunId }) // [!code highlight]
        .then(() => ({ kind: "upgrade" as const })), // [!code highlight]
    ]);

    if (event.kind === "upgrade") { // [!code highlight]
      // Checkpoint current state and hand off to a fresh run
      // on whatever deployment is live now. THIS run ends here.
      await spawnSelfOnLatest(state); // [!code highlight]
      return; // [!code highlight]
    }

    await processItem(event.payload.itemId);
    state = {
      processed: state.processed + 1,
      cursor: event.payload.itemId,
    };
  }
}
```

### Triggering the upgrade

Expose a separate endpoint that resumes `upgradeHook` for a given run. Call it from your deploy pipeline, an admin UI, or a fan-out script that iterates over every active run after shipping a fix.

```typescript
import { upgradeHook } from "@/workflows/long-running-queue";

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

  // The workflow exits its loop, captures state, and respawns
  // on the latest deployment.
  await upgradeHook.resume(runId, { reason }); // [!code highlight]

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

To upgrade a fleet of runs after a deploy, list active runs (e.g. from a tracking store) and call this endpoint for each.

## How it works

1. **`deploymentId: "latest"` is the upgrade knob.** Without it, the spawn pins to the current deployment. With it, the new run resolves to whatever deployment is current when the runtime picks it up — so any shipped fix applies starting from that respawn. Both methods rely on this.
2. **`start()` from a step.** [`start()`](/docs/api-reference/workflow-api/start) is not allowed directly inside `"use workflow"` functions — wrap it in a `"use step"` helper to keep the spawn deterministic across replays.
3. **State carries through the function argument.** The accumulating context flows from run N to run N+1 as a serialized argument. No external store is required for the state itself.
4. **Per-run hook tokens.** Using `workflowRunId` as the hook token scopes each iteration's wait to its own run, so multiple chains can run concurrently without interfering.
5. **Method 1 vs Method 2 is just where the spawn happens.** In Method 1 every run spawns its successor unconditionally before exiting — there is no long-lived process to migrate. In Method 2 the spawn happens only when the upgrade hook fires; otherwise the loop keeps handling events on the same run.

## Adapting to your use case

* **Combine with a sleep.** Race the hook against `sleep()` so iterations also tick on a timer: `Promise.race([hook, sleep("1d")])` lets the workflow advance even if no external event arrives.
* **Stateless successors.** If the next iteration doesn't need the previous state (e.g. a pure event router), call `start(longRunningQueue, [], { deploymentId: "latest" })` and skip the argument plumbing.
* **Persist state externally.** If state needs to be readable from outside the workflow (dashboards, debugging, recovery), write it to a database in a step before spawning the next run.
* **Track the active runId externally.** Whatever resumes the hook needs to know the current run. Have the spawn step write the new `runId` to a KV/database keyed by a stable session identifier so resumers always look up the latest one.

## Caveats

* **Backward compatibility matters.** Because the next run executes on a different deployment, the workflow's input arguments and return type must remain compatible across deployments. Adding required fields, removing fields, or changing types can cause serialization failures. See the [`deploymentId: "latest"` callout](/docs/api-reference/workflow-api/start#using-deploymentid-latest).
* **Workflow identity is the function name + file path.** Renaming the function or moving the file across a deployment changes the workflow ID — the next iteration will fail to resolve. Treat the workflow's name and location as stable interfaces.
* **There is a tiny gap between iterations.** The current run ends as soon as `start()` returns; the next run starts asynchronously. A resume that arrives in that window can fail with "hook not found." Make resumers retry, or have the API persist pending payloads and apply them once the next iteration is ready.
* **Method 2: track active runs externally.** Because Method 2's runs are long-lived, the set of in-flight runs only changes when one starts, completes, or upgrades. Persist run IDs (and clean them up on completion or upgrade) so a rollout script can fan out reliably. After resuming `upgradeHook`, also update the tracked run ID once the new run reports back, the same way you would in Method 1.
* **`start()` must be called from a step**, never directly from the workflow body.

## Key APIs

* [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function
* [`"use step"`](/docs/foundations/workflows-and-steps) — required wrapper for `start()` calls
* [`start()`](/docs/api-reference/workflow-api/start) with [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) — spawn the successor on the newest deployment
* [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspend the workflow until an external event resumes it
* [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) — exposes `workflowRunId` for per-run hook tokens


---
title: Agent Cancellation
description: Cancel a running agent from the outside — either immediately via run.cancel() or gracefully via a stop signal hook.
type: guide
summary: Two patterns for cancelling a running agent — Hard Cancellation via getRun(runId).cancel() for forced termination, or Stop Signal via a hook + Promise.race for a clean exit with cleanup and final stream notification.
---

# Agent Cancellation



<Callout type="warn">
  This recipe uses the deprecated `DurableAgent` API. For new agents, use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) and follow the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). The cancellation patterns here (`run.cancel()`, stop-signal hook + `Promise.race`) apply to either API.
</Callout>

Cancel a running agent from the outside — for example, a "Stop" button in a chat UI, an admin cancellation endpoint, or a timeout fallback. Two patterns are available depending on whether you need the agent to exit cleanly or just need the run to stop: **Hard Cancellation** via `getRun(runId).cancel()` for immediate forced termination, or **Stop Signal** via a hook + `Promise.race` for a graceful exit that runs cleanup and notifies streaming clients before returning.

## When to use this

* **Chat stop buttons** — let users cancel a long-running agent from the browser
* **Admin cancellation** — stop an agent from a different process or API
* **Timeout fallback** — combine with `sleep()` to auto-stop after a deadline

## Choosing an approach

Pick the option that matches what your endpoint needs to deliver to the caller:

* **Hard Cancellation** — terminates the run immediately with no opportunity for cleanup or client notification. A single line of code, but the workflow throws `WorkflowRunCancelledError` and any streaming clients see an abrupt connection close.
* **Stop Signal** — the workflow exits as soon as the hook fires, runs any pending cleanup, emits a final `data-stopped` part to the stream so the client can render cleanly, and returns a real result.

The trade-offs at a glance:

|                           | Hard Cancellation                              | Stop Signal                                  |
| ------------------------- | ---------------------------------------------- | -------------------------------------------- |
| Mechanism                 | `getRun(runId).cancel()`                       | Hook + `Promise.race`                        |
| Speed to terminate        | Immediate                                      | At the next `await` boundary in the workflow |
| Runs `finally` / cleanup  | No                                             | Yes                                          |
| Final stream notification | No (abrupt close)                              | Yes (`data-stopped` part)                    |
| `run.returnValue`         | Throws `WorkflowRunCancelledError`             | Returns the workflow's result                |
| Code complexity           | One line                                       | Hook + race + signal step                    |
| Best for                  | Stuck or unresponsive runs, forced termination | User-facing stop, admin cancel, timeouts     |

## Hard Cancellation

Call `.cancel()` on a run to terminate it immediately:

```typescript lineNumbers
import { getRun } from "workflow/api";

export async function POST(
  _request: Request,
  { params }: { params: Promise<{ runId: string }> }
) {
  const { runId } = await params;
  await getRun(runId).cancel(); // [!code highlight]
  return Response.json({ success: true });
}
```

This is an abrupt termination — the run is stopped mid-step with no opportunity to exit cleanly:

* **No cleanup runs** — `finally` blocks, defer-style step cleanup, and any logic after the current step are all skipped
* **No final notification to the client** — the writable closes abruptly, so a streaming UI just sees the connection drop with no `data-stopped` part to render a clean ending
* **`run.returnValue` throws** — anyone awaiting the result receives [`WorkflowRunCancelledError`](/docs/api-reference/workflow-errors/workflow-run-cancelled-error) instead of a meaningful payload
* **Underlying step keeps running** — same caveat as the Stop Signal pattern below: the model stream or HTTP call inside the current step continues to completion in the background

Hard Cancellation is the appropriate choice when the run is stuck or unresponsive, has exceeded its expected runtime, or you don't need a clean exit. For everything else — chat stop buttons, admin "stop" actions, timeout fallbacks — you typically want the Stop Signal pattern: the agent finishes its current step, emits a final stream part so the client renders a clean ending, and returns a real result.

## Stop Signal

<Callout type="warn">
  **Limitation:** This pattern does not cancel the underlying model stream. The agent step writing to the writable continues running in the background until it completes — tokens generated after the stop signal are still produced (and billed by your model provider). What this pattern *does* is exit the workflow function as soon as the hook fires and emit a `data-stopped` part so the client can stop rendering. For hard cross-process cancellation that signals the inner step to bail out, see [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller).
</Callout>

### Example

```typescript lineNumbers
import { DurableAgent } from "@workflow/ai/agent";
import { defineHook, getWritable, getWorkflowMetadata } from "workflow";
import { z } from "zod";
import type { ModelMessage, UIMessageChunk } from "ai";

export const stopHook = defineHook({
  schema: z.object({ reason: z.string().optional() }),
});

async function searchWeb({ query }: { query: string }) {
  "use step";
  await new Promise((r) => setTimeout(r, 1500));
  return { results: [{ title: `${query} - Wikipedia`, snippet: `Overview of ${query}...` }] };
}

async function analyzeData({ topic }: { topic: string }) {
  "use step";
  await new Promise((r) => setTimeout(r, 1200));
  return { summary: `Analysis of ${topic}: significant developments found.`, confidence: 0.85 };
}

async function emitStopSignal(details: { reason?: string }) { // [!code highlight]
  "use step";
  const writer = getWritable<UIMessageChunk>().getWriter();
  try {
    await writer.write({ type: "data-stopped", id: "stop-signal", data: details } as UIMessageChunk);
  } finally {
    writer.releaseLock();
  }
}

export async function stoppableAgent(messages: ModelMessage[]) {
  "use workflow";

  const { workflowRunId } = getWorkflowMetadata();
  const hook = stopHook.create({ token: `stop:${workflowRunId}` }); // [!code highlight]

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    instructions: "You are a research assistant. Search and analyze data as needed.",
    tools: {
      searchWeb: {
        description: "Search the web for information",
        inputSchema: z.object({ query: z.string() }),
        execute: searchWeb,
      },
      analyzeData: {
        description: "Analyze a piece of data",
        inputSchema: z.object({ topic: z.string() }),
        execute: analyzeData,
      },
    },
  });

  const result = await Promise.race([ // [!code highlight]
    agent
      .stream({ messages, writable: getWritable<UIMessageChunk>(), maxSteps: 15 })
      .then((r) => ({ type: "complete" as const, messages: r.messages })),
    hook.then(({ reason }) => ({ type: "stopped" as const, reason })), // [!code highlight]
  ]);

  if (result.type === "stopped") {
    await emitStopSignal({ reason: result.reason }); // [!code highlight]
  }

  return result;
}
```

### API Route to Trigger Stop

```typescript lineNumbers
import { stopHook } from "@/workflows/stoppable-agent";

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

  await stopHook.resume(`stop:${runId}`, { // [!code highlight]
    reason: reason || "User requested stop",
  });

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

### Client Stop Button

```tsx lineNumbers
"use client";

export function StopButton({ runId }: { runId: string }) {
  const handleStop = async () => {
    await fetch(`/api/chat/${runId}/stop`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ reason: "User clicked stop" }),
    });
  };

  return (
    <button type="button" onClick={handleStop}>
      Stop Agent
    </button>
  );
}
```

## How it works

1. A hook is created with token `stop:${workflowRunId}` when the workflow starts
2. `Promise.race` runs the agent stream and the stop hook concurrently
3. When the stop API resumes the hook, the race resolves immediately — the workflow exits
4. Before returning, `emitStopSignal` writes a `data-stopped` part to the stream so the client knows the agent was stopped (not just disconnected)
5. The client detects `data-stopped` and updates the UI accordingly

This is the same pattern used by the [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller) — race a long-running operation against a hook signal.

## Adapting this

* **Add a timeout** — race a third `sleep()` promise to auto-stop after a deadline
* **Audit logging** — include a `reason` field in the stop schema to record who stopped and why
* **Cross-process** — the hook token is deterministic, so any process can call `stopHook.resume()` with the run ID
* **Step limits** — combine with `maxSteps` on the agent to cap execution even without manual stop
* **Hard Cancellation as a fallback** — wire your stop endpoint to fall back to `getRun(runId).cancel()` if the hook resume errors with `not found` / `expired` (for example, the hook was already consumed). This guarantees the run is terminated even when the Stop Signal path is unavailable.

## Key APIs

* [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook for the stop signal
* [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) — access the run ID for deterministic hook tokens
* [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream a stop notification to the client
* [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) — AI SDK's durable agent that gets raced against the stop hook (replaces `DurableAgent`)
* [`getRun()`](/docs/api-reference/workflow-api/get-run) — entry point for Hard Cancellation: `getRun(runId).cancel()`


---
title: DurableAgent is now WorkflowAgent
description: Use AI SDK v7's WorkflowAgent for durable, resumable AI agents.
type: guide
summary: Build durable, resumable AI agents with AI SDK v7's WorkflowAgent.
---

# DurableAgent is now WorkflowAgent



## WorkflowAgent from AI SDK v7

Use AI SDK v7's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) for new durable agent work. It replaces `DurableAgent` and keeps the current agent pattern in the AI SDK package.

* Import `WorkflowAgent` from `@ai-sdk/workflow` and run it inside a `"use workflow"` function.
* Stream `ModelCallStreamPart` chunks with `getWritable()`, then convert the run stream to UI message chunks with `createModelCallToUIChunkTransform()` in your route.
* Mark tool `execute` functions with `"use step"` when they should run as durable workflow steps with retry and observability behavior.

<Callout type="warn">
  `DurableAgent` is deprecated and remains documented for existing code only. See the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent), or the [`DurableAgent` API reference](/docs/api-reference/workflow-ai/durable-agent) while migrating.
</Callout>


---
title: Human-in-the-Loop
description: Pause an AI agent to wait for human approval, then resume based on the decision.
type: guide
summary: Use defineHook with the tool call ID to suspend an agent for human approval, with an optional timeout.
---

# Human-in-the-Loop



<Callout type="warn">
  This recipe uses the deprecated `DurableAgent` API. For new agents, use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) and follow the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). The human-in-the-loop pattern here (hooks, `Promise.race`, approval gating) applies to either API.
</Callout>

Use this pattern when an AI agent needs human confirmation before performing a consequential action like booking, purchasing, or publishing. The workflow suspends without consuming resources until the human responds.

## When to use this

* Booking confirmations where users must approve before charges are made
* Content publishing gates where an editor must sign off
* Any agent action where the cost of getting it wrong justifies a human check
* Actions with side effects that can't be easily undone

## Pattern

Create a typed hook using `defineHook()`. When the agent calls the approval tool, the tool emits a custom data part to the stream so the client can render approval controls, then creates a hook and suspends. An API route resumes the hook with the decision.

### Workflow

```typescript
import { DurableAgent } from "@workflow/ai/agent";
import { defineHook, sleep, getWritable } from "workflow";
import { z } from "zod";
import type { ModelMessage, UIMessageChunk } from "ai";

// Exported so the approval API route can call .resume()
export const bookingApprovalHook = defineHook({ // [!code highlight]
  schema: z.object({
    approved: z.boolean(),
    comment: z.string().optional(),
  }),
});

async function searchFlights({ from, to, date }: {
  from: string;
  to: string;
  date: string;
}) {
  "use step";
  const res = await fetch(
    `https://api.example.com/flights?from=${from}&to=${to}&date=${date}`
  );
  return res.json();
}

async function confirmBooking({ flightId, passenger }: {
  flightId: string;
  passenger: string;
}) {
  "use step";
  const res = await fetch("https://api.example.com/bookings", {
    method: "POST",
    body: JSON.stringify({ flightId, passenger }),
  });
  return res.json();
}

// Stream a custom data part so the client can render the approval UI.
// This MUST run before the hook suspends the workflow — otherwise
// the tool-invocation won't appear in the stream until the tool returns,
// and the client would have no way to show approval buttons.
async function emitApprovalRequest(details: {
  flightId: string;
  passenger: string;
  price: number;
  toolCallId: string;
}) {
  "use step";
  const writer = getWritable<UIMessageChunk>().getWriter();
  try {
    await writer.write({
      type: "data-approval-needed", // [!code highlight]
      id: details.toolCallId,
      data: details,
    } as UIMessageChunk);
  } finally {
    writer.releaseLock();
  }
}

// Stream the resolution so the client can update the approval card.
async function emitApprovalResolved(details: {
  toolCallId: string;
  result: string;
}) {
  "use step";
  const writer = getWritable<UIMessageChunk>().getWriter();
  try {
    await writer.write({
      type: "data-approval-resolved", // [!code highlight]
      id: details.toolCallId,
      data: details,
    } as UIMessageChunk);
  } finally {
    writer.releaseLock();
  }
}

// No "use step" — hooks are workflow-level primitives
async function requestBookingApproval(
  { flightId, passenger, price }: {
    flightId: string;
    passenger: string;
    price: number;
  },
  { toolCallId }: { toolCallId: string }
) {
  // Emit to the stream before suspending so the UI can show buttons
  await emitApprovalRequest({ flightId, passenger, price, toolCallId }); // [!code highlight]

  const hook = bookingApprovalHook.create({ token: toolCallId });

  // Race: human decision vs. timeout
  const result = await Promise.race([
    hook.then((payload) => ({ type: "decision" as const, ...payload })),
    sleep("24h").then(() => ({ type: "timeout" as const, approved: false as const })),
  ]);

  if (result.type === "timeout") {
    const msg = "Booking request expired.";
    await emitApprovalResolved({ toolCallId, result: msg }); // [!code highlight]
    return msg;
  }
  if (!result.approved) {
    const msg = `Rejected: ${result.comment || "No reason given"}`;
    await emitApprovalResolved({ toolCallId, result: msg }); // [!code highlight]
    return msg;
  }

  const booking = await confirmBooking({ flightId, passenger });
  const msg = `Booked! Confirmation: ${booking.confirmationId}`;
  await emitApprovalResolved({ toolCallId, result: msg }); // [!code highlight]
  return msg;
}

export async function bookingAgent(messages: ModelMessage[]) {
  "use workflow";

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    instructions: "You help book flights. Always request approval before booking.",
    tools: {
      searchFlights: {
        description: "Search for available flights",
        inputSchema: z.object({
          from: z.string().describe("Departure airport code"),
          to: z.string().describe("Arrival airport code"),
          date: z.string().describe("Travel date (YYYY-MM-DD)"),
        }),
        execute: searchFlights,
      },
      requestBookingApproval: {
        description: "Request human approval before booking a flight",
        inputSchema: z.object({
          flightId: z.string().describe("Flight ID to book"),
          passenger: z.string().describe("Passenger name"),
          price: z.number().describe("Total price"),
        }),
        execute: requestBookingApproval,
      },
    },
  });

  await agent.stream({
    messages,
    writable: getWritable<UIMessageChunk>(),
  });
}
```

### Approval API route

The approval route imports the hook definition and calls `.resume()` with the tool call ID as the token:

```typescript
import { bookingApprovalHook } from "@/app/workflows/booking-agent";

export async function POST(req: Request) {
  const { toolCallId, approved, comment } = await req.json();

  await bookingApprovalHook.resume(toolCallId, { approved, comment }); // [!code highlight]

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

### Client rendering

Listen for `data-approval-needed` and `data-approval-resolved` custom data parts in the message stream. The approval tool invocation itself won't appear until the tool returns, so the custom data parts are the mechanism for showing and updating the approval UI.

```tsx
// Scan all messages for the resolution
const approvalResult = messages
  .flatMap((m) => m.parts)
  .find((p) => p.type === "data-approval-resolved")
  ?.data?.result;

// In your message parts loop:
{message.parts.map((part, i) => {
  if (part.type === "data-approval-needed") { // [!code highlight]
    const { flightId, passenger, price, toolCallId } = part.data;
    if (approvalResult) {
      return <div key={i}>Result: {approvalResult}</div>;
    }
    return (
      <div key={i} className="rounded-lg border p-4 space-y-3">
        <div className="text-sm">
          <div>Flight: {flightId}</div>
          <div>Passenger: {passenger}</div>
          <div>Price: ${price}</div>
        </div>
        <div className="flex gap-2">
          <button onClick={() => approve(toolCallId)}>Approve</button> {/* [!code highlight] */}
          <button onClick={() => reject(toolCallId)}>Reject</button> {/* [!code highlight] */}
        </div>
      </div>
    );
  }
  // Hide the requestBookingApproval tool-invocation part
  if (part.type === "tool-invocation" &&
      part.toolInvocation.toolName === "requestBookingApproval") {
    return null;
  }
  // ... other part types
})}
```

## How it works

1. **`defineHook()` with schema** — creates a typed hook with Zod validation. The approval payload is validated before the workflow receives it.
2. **`toolCallId` as token** — the approval tool uses the tool call ID as the hook token, naturally linking the hook to the specific tool invocation.
3. **`emitApprovalRequest` step** — writes a `data-approval-needed` custom data part to the stream *before* the hook suspends. Without this, the client would never see the approval controls because tool invocations don't stream until the tool returns.
4. **No `"use step"` on the approval tool** — the tool runs at the workflow level because `defineHook().create()` is a workflow primitive. It calls step functions (`emitApprovalRequest`, `emitApprovalResolved`, `confirmBooking`) for I/O.
5. **`Promise.race` with sleep** — the approval races against a durable timeout. If nobody responds, the workflow continues with an expiration message.
6. **`emitApprovalResolved` step** — writes the outcome to the stream so the client can update the card immediately, without waiting for the tool-invocation result.

## Adapting to your use case

* **Change the approval schema** — add fields like `reason`, `amount`, `reviewerEmail` to match your domain.
* **Multiple approval gates** — the pattern works for any number of tools. Each tool creates its own hook with its own `toolCallId`.
* **Escalation** — if the first approver doesn't respond, use `sleep()` + another hook to escalate to a backup reviewer.
* **Adjust timeout** — use `"24h"` for production, shorter durations for demos.
* **Workflow-level vs step tools** — tools that use `sleep()`, `defineHook()`, or other workflow primitives must NOT use `"use step"`. Tools with only I/O (API calls, DB queries) should use `"use step"` for retries.

## Key APIs

* [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) — declares the orchestrator function
* [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — declares step functions with retries
* [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook with schema validation
* [`sleep()`](/docs/api-reference/workflow/sleep) — durable timeout for approval expiry
* [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream custom data parts from steps
* [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) — AI SDK's durable agent (replaces `DurableAgent`)


---
title: Batching & Parallel Processing
description: Process large collections in parallel batches with failure isolation between groups.
type: guide
summary: Split items into fixed-size batches, process each batch concurrently with Promise.allSettled, and pace batches with sleep to avoid overloading downstream services.
---

# Batching & Parallel Processing



Use batching when you need to process a large list of items in parallel while controlling concurrency. Items are split into fixed-size batches, each batch runs concurrently, and failures in one batch don't affect others.

## When to use this

* Bulk data imports (contacts, orders, products from a CSV)
* Processing hundreds or thousands of items against external APIs
* Calling rate-limited APIs where you need to control concurrency
* Any fan-out where you want failure isolation between groups

## How it works

1. Records are split into fixed-size batches.
2. Each batch runs in parallel via `Promise.allSettled` — failures in one record don't affect others.
3. A `sleep()` between batches paces requests to avoid overloading downstream services.
4. After all batches, a summary is returned with succeeded/failed counts.

## Pattern

The workflow splits records into chunks, processes each chunk concurrently, tracks results per batch, and returns a final tally.

```typescript
import { sleep } from "workflow";

type Record = { name: string; email: string; role: string };

declare function processRecord(record: Record): Promise<string>; // @setup

export async function batchImport(records: Record[], batchSize: number) {
  "use workflow";

  let totalSucceeded = 0;
  let totalFailed = 0;

  for (let i = 0; i < records.length; i += batchSize) {
    const batch = records.slice(i, i + batchSize);

    // Run batch in parallel — failures are isolated per record
    const outcomes = await Promise.allSettled( // [!code highlight]
      batch.map((record) => processRecord(record))
    );

    for (let j = 0; j < outcomes.length; j++) {
      if (outcomes[j].status === "fulfilled") {
        totalSucceeded++;
      } else {
        totalFailed++;
      }
    }

    // Pace between batches to avoid overloading downstream
    if (i + batchSize < records.length) {
      await sleep("1s"); // [!code highlight]
    }
  }

  return { total: records.length, succeeded: totalSucceeded, failed: totalFailed };
}
```

### Step function

Each record is processed in its own step with full Node.js access and automatic retries.

```typescript
type Record = { name: string; email: string; role: string };

async function processRecord(record: Record): Promise<string> {
  "use step";
  const res = await fetch(`https://api.example.com/contacts`, {
    method: "POST",
    body: JSON.stringify(record),
  });
  if (!res.ok) throw new Error(`Failed to import ${record.email}`);
  const { id } = await res.json();
  return id;
}
```

## Adapting to your use case

* Replace the `Record` type with your actual data shape (orders, images, products, etc.).
* Replace `processRecord()` with your real import logic — DB upserts, API calls, file processing.
* Tune `batchSize` and the `sleep()` duration to match your downstream rate limits.
* Add or remove tracking as needed — the pattern works with any item type.

## Tips

* **Use `Promise.allSettled` over `Promise.all`** when you want to continue even if some items fail. `Promise.all` rejects on the first failure; `allSettled` waits for everything and tells you what failed.
* **Tune batch size to your downstream API limits.** If the API allows 10 concurrent requests, use `batchSize: 10`.
* **Add pacing with `sleep()`** between batches to respect rate limits. The sleep is durable — it survives cold starts.
* **Each `processRecord` call is an independent step.** If one fails, it retries up to 3 times without affecting other items in the batch.

## Key APIs

* [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
* [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
* [`sleep()`](/docs/api-reference/workflow/sleep) -- pacing delay between batches
* [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) -- runs items in parallel, isolating failures


---
title: Idempotency
description: Make step retries safe and coordinate duplicate workflow starts with hook tokens.
type: guide
summary: Use step IDs for retry-safe external calls, and use deterministic hook tokens when duplicate requests must route to one active workflow.
---

# Idempotency



Use idempotency when a retry or duplicate request should not repeat the underlying work. In Workflow, there are two common patterns: use the step ID for retry-safe external calls, and use hook tokens to coordinate duplicate workflow starts.

## When to use this

* A step charges a payment, sends an email, enqueues work, or creates an external record.
* A route may receive duplicate requests that should map to one active workflow run.

## Step idempotency

Every step has a unique, deterministic `stepId` available via `getStepMetadata()`. Pass this as the idempotency key to external APIs:

```typescript
import { getStepMetadata } from "workflow";

export async function createCharge(
  customerId: string,
  amount: number
): Promise<{ id: string }> {
  "use step";

  const { stepId } = getStepMetadata(); // [!code highlight]

  // Stripe uses the idempotency key to deduplicate requests.
  // If this step is retried, Stripe returns the same charge.
  const charge = await fetch("https://api.stripe.com/v1/charges", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
      "Idempotency-Key": stepId, // [!code highlight]
    },
    body: new URLSearchParams({
      amount: String(amount),
      currency: "usd",
      customer: customerId,
    }),
  });

  if (!charge.ok) {
    const error = await charge.json();
    throw new Error(`Charge failed: ${error.message}`);
  }

  return charge.json();
}
```

See [Step Idempotency](/docs/foundations/idempotency#step-idempotency) for why `stepId` is stable across retries and how to think about external API conflicts.

## Run idempotency

For duplicate workflow-start requests, derive a hook token from your domain key. You can avoid obvious duplicate starts by checking whether an active hook already owns that token before calling `start()`:

```typescript
import { getHookByToken, start } from "workflow/api";
import { HookNotFoundError } from "workflow/errors";
import { processOrder } from "./workflows/process-order";

export async function POST(request: Request) {
  const { orderId } = await request.json();
  const token = `order:${orderId}`;

  try {
    const hook = await getHookByToken(token); // [!code highlight]
    return Response.json({ runId: hook.runId, reused: true });
  } catch (error) {
    if (!HookNotFoundError.is(error)) throw error;
  }

  const run = await start(processOrder, [orderId]); // [!code highlight]
  return Response.json({ runId: run.runId, reused: false });
}
```

The workflow should create the deterministic hook and check `await hook.getConflict()` before duplicate-sensitive work — awaiting `getConflict()` suspends the workflow to commit the hook registration and resolves with the conflicting run when another active run already owns the token (or `null` once the hook is registered). See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for the full pattern, including how to steer an active run with `resumeHook()` and how to handle the current race between `start()` and hook registration.

## Key APIs

* [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) -- declares the orchestrator function
* [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) -- declares step functions with full Node.js access
* [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) -- provides the deterministic `stepId` for idempotency keys
* [`createHook()`](/docs/api-reference/workflow/create-hook) -- creates a hook with an optional deterministic token
* [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) -- finds the active hook for a token
* [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) -- resumes the active hook when the duplicate request carries data
* [`start()`](/docs/api-reference/workflow-api/start) -- starts a new workflow run


---
title: Rate Limiting & Retries
description: Handle 429 responses and transient failures with RetryableError and exponential backoff.
type: guide
summary: When an external API returns 429, throw RetryableError with the Retry-After value so the workflow runtime automatically reschedules the step after the specified delay.
---

# Rate Limiting & Retries



Use this pattern when calling external APIs that enforce rate limits. Instead of writing manual retry loops, throw `RetryableError` with a `retryAfter` value and let the workflow runtime handle rescheduling.

## When to use this

* Calling APIs that return 429 (Too Many Requests) with `Retry-After` headers
* Any step that hits transient failures and needs backoff
* Syncing data with third-party services (Stripe, CRMs, scrapers)

## Pattern: RetryableError with Retry-After

A step function calls an external API. On 429, it reads the `Retry-After` header and throws `RetryableError`. The runtime reschedules the step automatically.

```typescript
import { RetryableError } from "workflow";

declare function fetchFromCrm(contactId: string): Promise<unknown>; // @setup
declare function upsertToWarehouse(contactId: string, contact: unknown): Promise<void>; // @setup

export async function syncContact(contactId: string) {
  "use workflow";

  const contact = await fetchFromCrm(contactId);
  await upsertToWarehouse(contactId, contact);

  return { contactId, status: "synced" };
}
```

### Step function with rate limit handling

```typescript
import { RetryableError } from "workflow";

async function fetchFromCrm(contactId: string) {
  "use step";

  const res = await fetch(`https://crm.example.com/contacts/${contactId}`);

  if (res.status === 429) { // [!code highlight]
    const retryAfter = res.headers.get("Retry-After");
    throw new RetryableError("Rate limited by CRM", { // [!code highlight]
      retryAfter: retryAfter ? parseInt(retryAfter) * 1000 : "1m",
    });
  }

  if (!res.ok) throw new Error(`CRM returned ${res.status}`);
  return res.json();
}

async function upsertToWarehouse(contactId: string, contact: unknown) {
  "use step";
  await fetch(`https://warehouse.example.com/contacts/${contactId}`, {
    method: "PUT",
    body: JSON.stringify(contact),
  });
}
```

## Pattern: Exponential backoff

Use `getStepMetadata()` to access the current attempt number and calculate increasing delays:

```typescript
import { RetryableError, getStepMetadata } from "workflow";

async function callFlakeyApi(endpoint: string) {
  "use step";

  const { attempt } = getStepMetadata(); // [!code highlight]
  const res = await fetch(endpoint);

  if (res.status === 429 || res.status >= 500) {
    throw new RetryableError(`Request failed (${res.status})`, { // [!code highlight]
      retryAfter: (attempt ** 2) * 1000, // 1s, 4s, 9s... // [!code highlight]
    });
  }

  return res.json();
}
```

## Pattern: Circuit breaker with sleep

When a dependency is completely down, stop hitting it for a cooldown period using `sleep()`, then probe with a single test request:

```typescript
import { sleep } from "workflow";

export async function circuitBreaker(maxRequests: number = 10) {
  "use workflow";

  let state: "closed" | "open" | "half-open" = "closed";
  let consecutiveFailures = 0;
  const FAILURE_THRESHOLD = 3;

  for (let i = 1; i <= maxRequests; i++) {
    if (state === "open") {
      await sleep("30s"); // Durable cooldown // [!code highlight]
      state = "half-open";
    }

    const success = await callService(i);

    if (success) {
      consecutiveFailures = 0;
      if (state === "half-open") state = "closed";
    } else {
      consecutiveFailures++;
      if (consecutiveFailures >= FAILURE_THRESHOLD) {
        state = "open";
        consecutiveFailures = 0;
      }
    }
  }

  return { status: state === "closed" ? "recovered" : "failed" };
}

async function callService(requestNum: number): Promise<boolean> {
  "use step";
  try {
    const res = await fetch("https://payment-gateway.example.com/charge");
    return res.ok;
  } catch {
    return false;
  }
}
```

## Pattern: Custom max retries

Override the default retry count (3) for steps that need more or fewer attempts:

```typescript
async function fetchWithRetries(url: string) {
  "use step";
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Failed: ${res.status}`);
  return res.json();
}

// Allow up to 10 retry attempts
fetchWithRetries.maxRetries = 10; // [!code highlight]
```

## Application-level retry

Sometimes you need retry logic at the workflow level -- wrapping a step call with your own backoff instead of relying on the framework's built-in `RetryableError`. This is useful when you want full control over retry conditions, delays, and error filtering.

```typescript
interface RetryOptions {
  maxRetries?: number;
  baseDelay?: number;
  maxDelay?: number;
  shouldRetry?: (error: Error, attempt: number) => boolean;
}

async function withRetry<T>(
  fn: () => Promise<T>,
  options: RetryOptions = {},
): Promise<T> {
  const { maxRetries = 3, baseDelay = 2000, maxDelay = 10000, shouldRetry } = options;
  let lastError: Error | undefined;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error instanceof Error ? error : new Error(String(error));
      const isLastAttempt = attempt === maxRetries;
      if (isLastAttempt || (shouldRetry && !shouldRetry(lastError, attempt + 1))) {
        throw lastError;
      }
      // Exponential backoff with jitter
      const delay = Math.min(baseDelay * 2 ** attempt * (0.5 + Math.random() * 0.5), maxDelay);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }

  throw lastError;
}
```

Use it in a workflow to wrap step calls:

```typescript
declare function withRetry<T>(fn: () => Promise<T>, options?: { maxRetries?: number; shouldRetry?: (error: Error) => boolean }): Promise<T>; // @setup
declare function downloadFile(url: string): Promise<any>; // @setup

export async function downloadWithRetry(url: string) {
  "use workflow";

  const result = await withRetry(() => downloadFile(url), { // [!code highlight]
    maxRetries: 5,
    shouldRetry: (error) => error.message.includes("Timeout"),
  });

  return result;
}
```

**When to use this vs `RetryableError`/`FatalError`:**

* **`RetryableError`** runs inside a step -- the framework reschedules the step after the delay. Use it for transient HTTP errors (429, 503) where the runtime should handle backoff.
* **Application-level retry** wraps the step call from the workflow. Use it when you need custom retry conditions, want to retry across different steps, or when you're building a library and prefer not to depend on workflow-specific error classes.

## Tips

* **`RetryableError` is for transient failures.** Use it when the request might succeed on a later attempt (429, 503, network timeout).
* **`FatalError` is for permanent failures.** Use it when retrying won't help (404, 401, invalid input). This skips all remaining retries.
* **The `retryAfter` option accepts** a millisecond number, a duration string (`"1m"`, `"30s"`), or a `Date` object.
* **Steps retry up to 3 times by default.** Set `fn.maxRetries = N` to change this per step function.
* **Don't write manual sleep-retry loops.** The runtime handles scheduling natively with `RetryableError` -- it's more efficient and survives cold starts.

## Key APIs

* [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
* [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
* [`RetryableError`](/docs/api-reference/workflow/retryable-error) -- signals the runtime to retry after a delay
* [`FatalError`](/docs/api-reference/workflow/fatal-error) -- signals a permanent failure, skipping retries
* [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) -- provides the current attempt number and step ID
* [`sleep()`](/docs/api-reference/workflow/sleep) -- durable pause for circuit breaker cooldowns


---
title: Transactions & Rollbacks (Saga)
description: Coordinate multi-step transactions with automatic rollback when a step fails.
type: guide
summary: Run a sequence of steps where each registers a compensation. If any step throws a FatalError, compensations execute in reverse order to restore consistency.
---

# Transactions & Rollbacks (Saga)



Use the saga pattern when a business transaction spans multiple services and you need automatic rollback if any step fails. Each forward step registers a compensation, and on failure the workflow unwinds them in reverse order.

## When to use this

* Multi-service transactions (reserve inventory, charge payment, provision access)
* Any sequence where partial completion leaves the system in an inconsistent state
* Operations that need "all or nothing" semantics across external APIs

## How it works

1. Each forward step does work and registers a compensation function.
2. If any step throws `FatalError`, the catch block runs compensations in reverse (LIFO) order to restore consistency.
3. Regular errors are retried automatically (up to 3x by default). Use `FatalError` only for permanent failures where retrying won't help.

## Pattern

Each step returns a result and pushes a compensation handler onto a stack. If a later step throws a `FatalError`, the workflow catches it and executes compensations in LIFO order.

```typescript
import { FatalError } from "workflow";

declare function reserveSeats(accountId: string, seats: number): Promise<string>; // @setup
declare function releaseSeats(accountId: string, reservationId: string): Promise<void>; // @setup
declare function captureInvoice(accountId: string, seats: number): Promise<string>; // @setup
declare function refundInvoice(accountId: string, invoiceId: string): Promise<void>; // @setup
declare function provisionSeats(accountId: string, seats: number): Promise<string>; // @setup
declare function deprovisionSeats(accountId: string, entitlementId: string): Promise<void>; // @setup
declare function sendConfirmation(accountId: string, invoiceId: string, entitlementId: string): Promise<void>; // @setup

export async function subscriptionUpgradeSaga(accountId: string, seats: number) {
  "use workflow";

  const compensations: Array<() => Promise<void>> = [];

  try {
    const reservationId = await reserveSeats(accountId, seats);
    compensations.push(() => releaseSeats(accountId, reservationId)); // [!code highlight]

    const invoiceId = await captureInvoice(accountId, seats);
    compensations.push(() => refundInvoice(accountId, invoiceId)); // [!code highlight]

    const entitlementId = await provisionSeats(accountId, seats);
    compensations.push(() => deprovisionSeats(accountId, entitlementId)); // [!code highlight]

    // No compensation — notifications are fire-and-forget
    await sendConfirmation(accountId, invoiceId, entitlementId);

    return { status: "completed" };
  } catch (error) {
    // Unwind compensations in reverse (LIFO) order
    for (const compensate of compensations.reverse()) { // [!code highlight]
      await compensate(); // [!code highlight]
    }

    return { status: "rolled_back" };
  }
}
```

### Step functions

Each step is a `"use step"` function with full Node.js access (fetch, fs, npm packages). Forward steps do the work and throw `FatalError` on permanent failure; compensation steps undo it and must be idempotent — safe to call multiple times if the workflow restarts mid-rollback.

```typescript
import { FatalError } from "workflow";

// Forward steps

async function reserveSeats(accountId: string, seats: number): Promise<string> {
  "use step";
  const res = await fetch(`https://api.example.com/seats/reserve`, {
    method: "POST",
    body: JSON.stringify({ accountId, seats }),
  });
  if (!res.ok) throw new FatalError("Seat reservation failed"); // [!code highlight]
  const { reservationId } = await res.json();
  return reservationId;
}

async function captureInvoice(accountId: string, seats: number): Promise<string> {
  "use step";
  const res = await fetch(`https://api.example.com/invoices`, {
    method: "POST",
    body: JSON.stringify({ accountId, seats }),
  });
  if (!res.ok) throw new FatalError("Invoice capture failed"); // [!code highlight]
  const { invoiceId } = await res.json();
  return invoiceId;
}

async function provisionSeats(accountId: string, seats: number): Promise<string> {
  "use step";
  const res = await fetch(`https://api.example.com/entitlements`, {
    method: "POST",
    body: JSON.stringify({ accountId, seats }),
  });
  if (!res.ok) throw new FatalError("Provisioning failed"); // [!code highlight]
  const { entitlementId } = await res.json();
  return entitlementId;
}

async function sendConfirmation(
  accountId: string,
  invoiceId: string,
  entitlementId: string
): Promise<void> {
  "use step";
  await fetch(`https://api.example.com/notifications`, {
    method: "POST",
    body: JSON.stringify({ accountId, invoiceId, entitlementId, template: "upgrade-complete" }),
  });
}

// Compensation steps — must be idempotent

async function releaseSeats(accountId: string, reservationId: string): Promise<void> {
  "use step";
  await fetch(`https://api.example.com/seats/release`, {
    method: "POST",
    body: JSON.stringify({ accountId, reservationId }),
  });
}

async function refundInvoice(accountId: string, invoiceId: string): Promise<void> {
  "use step";
  await fetch(`https://api.example.com/invoices/${invoiceId}/refund`, {
    method: "POST",
    body: JSON.stringify({ accountId }),
  });
}

async function deprovisionSeats(accountId: string, entitlementId: string): Promise<void> {
  "use step";
  await fetch(`https://api.example.com/entitlements/${entitlementId}`, {
    method: "DELETE",
    body: JSON.stringify({ accountId }),
  });
}
```

### Streaming step progress (optional)

Use `getWritable()` to stream progress events to a UI so users can see each step execute in real time.

```typescript
import { FatalError } from "workflow";
import { getWritable } from "workflow";

type SagaEvent =
  | { type: "step_start"; step: string }
  | { type: "step_done"; step: string; detail: string }
  | { type: "step_failed"; step: string; error: string }
  | { type: "compensating"; step: string }
  | { type: "compensated"; step: string }
  | { type: "result"; status: "completed" | "rolled_back" };

async function emit(event: SagaEvent) {
  "use step";
  const writer = getWritable<SagaEvent>().getWriter();
  try {
    await writer.write(event);
  } finally {
    writer.releaseLock();
  }
}

declare function reserveSeats(accountId: string, seats: number): Promise<string>; // @setup
declare function releaseSeats(accountId: string, reservationId: string): Promise<void>; // @setup
declare function captureInvoice(accountId: string, seats: number): Promise<string>; // @setup
declare function refundInvoice(accountId: string, invoiceId: string): Promise<void>; // @setup
declare function provisionSeats(accountId: string, seats: number): Promise<string>; // @setup
declare function deprovisionSeats(accountId: string, entitlementId: string): Promise<void>; // @setup
declare function sendConfirmation(accountId: string, invoiceId: string, entitlementId: string): Promise<void>; // @setup

export async function subscriptionUpgradeSaga(accountId: string, seats: number) {
  "use workflow";

  const compensations: Array<{ name: string; execute: () => Promise<void> }> = [];

  try {
    await emit({ type: "step_start", step: "Reserve Seats" });
    const reservationId = await reserveSeats(accountId, seats);
    compensations.push({ name: "Release Seats", execute: () => releaseSeats(accountId, reservationId) });
    await emit({ type: "step_done", step: "Reserve Seats", detail: reservationId });

    await emit({ type: "step_start", step: "Capture Invoice" });
    const invoiceId = await captureInvoice(accountId, seats);
    compensations.push({ name: "Refund Invoice", execute: () => refundInvoice(accountId, invoiceId) });
    await emit({ type: "step_done", step: "Capture Invoice", detail: invoiceId });

    await emit({ type: "step_start", step: "Provision Seats" });
    const entitlementId = await provisionSeats(accountId, seats);
    compensations.push({ name: "Deprovision Seats", execute: () => deprovisionSeats(accountId, entitlementId) });
    await emit({ type: "step_done", step: "Provision Seats", detail: entitlementId });

    // No compensation — notifications are fire-and-forget
    await emit({ type: "step_start", step: "Send Confirmation" });
    await sendConfirmation(accountId, invoiceId, entitlementId);
    await emit({ type: "step_done", step: "Send Confirmation", detail: "sent" });

    await emit({ type: "result", status: "completed" });
    return { status: "completed" };
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : "Unknown error";
    await emit({ type: "step_failed", step: "failed", error: errorMessage });

    // Unwind compensations in reverse (LIFO) order
    for (const comp of compensations.reverse()) {
      await emit({ type: "compensating", step: comp.name });
      await comp.execute();
      await emit({ type: "compensated", step: comp.name });
    }

    await emit({ type: "result", status: "rolled_back" });
    return { status: "rolled_back" };
  }
}
```

## Adapting to your use case

* Replace the step functions with real API calls. Each `"use step"` function has full Node.js access.
* Add or remove steps as needed — the pattern scales to any number of steps.
* Make compensations idempotent — they may be retried if the workflow restarts mid-rollback.
* The `emit()` calls and `SagaEvent` type are optional — remove them if you don't need real-time UI progress.

## Tips

* **Use `FatalError` for permanent failures.** Regular errors trigger automatic retries (up to 3 by default). Throw `FatalError` when retrying won't help (e.g., insufficient funds, invalid input).
* **Make compensations idempotent.** If a compensation step is retried, it should produce the same result. Check whether the resource was already released before releasing it again.
* **Compensation steps are also `"use step"` functions.** This makes them durable — if the workflow restarts mid-rollback, it resumes where it left off.
* **Capture values in closures carefully.** Use block-scoped variables or copy values before pushing compensations to avoid referencing stale state.
* **Notifications don't need compensations.** Fire-and-forget steps like sending emails or Slack messages typically don't register a compensation.

## Key APIs

* [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) -- declares the orchestrator function
* [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) -- declares step functions with full Node.js access
* [`FatalError`](/docs/api-reference/workflow/fatal-error) -- non-retryable error that triggers compensation
* [`getWritable()`](/docs/api-reference/workflow/get-writable) -- streams data from workflows for real-time UI updates


---
title: Sleep, Scheduling & Timed Workflows
description: Use durable sleep to schedule actions minutes, hours, days, or weeks into the future.
type: guide
summary: Schedule future actions with durable sleep that survives cold starts, and race sleeps against hooks to let external events cancel the workflow early.
---

# Sleep, Scheduling & Timed Workflows



Workflow's `sleep()` is durable — it survives cold starts, restarts, and deployments. Combined with `defineHook()` and `Promise.race()`, it becomes the foundation for interruptible scheduled workflows like drip campaigns, reminders, and timed sequences.

<Callout type="info">
  Scheduled workflows are still pinned to the deployment that started them. If you are building recurring or indefinitely running schedules that should adopt newer code over time, see [Versioning](/docs/foundations/versioning) for the explicit `deploymentId: "latest"` continuation pattern.
</Callout>

## When to use this

* Sending emails on a schedule (drip campaigns, onboarding sequences, reminders)
* Waiting for a deadline but allowing early cancellation
* Any pattern where "do X, wait N hours, then do Y" needs to be both reliable and interruptible

## Drip campaign with cancellation

A drip campaign sends emails at intervals, sleeping between each. Each sleep races against a cancellation hook — if an external event fires the hook (e.g. user converts, unsubscribes), the campaign stops immediately.

```typescript
import { defineHook, sleep } from "workflow";

// Hook that any API route can fire to cancel the drip
export const cancelDrip = defineHook<{ reason?: string }>(); // [!code highlight]

async function sendEmail(email: string, template: string): Promise<void> {
  "use step";
  await fetch("https://api.sendgrid.com/v3/mail/send", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.SENDGRID_KEY}` },
    body: JSON.stringify({ to: [{ email }], template_id: template }),
  });
}

export async function emailSequence(email: string) {
  "use workflow";

  await sendEmail(email, "welcome");

  // Race durable sleep against the cancellation hook
  const hook = cancelDrip.create({ token: `cancel-drip:${email}` }); // [!code highlight]
  const cancelled = await Promise.race([ // [!code highlight]
    sleep("2d").then(() => false), // [!code highlight]
    hook.then(() => true), // [!code highlight]
  ]); // [!code highlight]
  if (cancelled) return { status: "cancelled", email };

  await sendEmail(email, "getting-started-tips");

  // Create a fresh hook for the next sleep window
  const hook2 = cancelDrip.create({ token: `cancel-drip:${email}` }); // [!code highlight]
  const cancelled2 = await Promise.race([ // [!code highlight]
    sleep("2d").then(() => false), // [!code highlight]
    hook2.then(() => true), // [!code highlight]
  ]); // [!code highlight]
  if (cancelled2) return { status: "cancelled", email };

  await sendEmail(email, "feature-highlights");

  return { status: "drip-complete", email };
}
```

### Cancelling from an API route

Any server-side code can fire the hook by calling `.resume()` with the same token:

```typescript
import { cancelDrip } from "@/workflows/email-sequence";

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

  if (!email) {
    return Response.json({ error: "email is required" }, { status: 400 });
  }

  try {
    await cancelDrip.resume(`cancel-drip:${email}`, { // [!code highlight]
      reason: reason ?? "User completed action", // [!code highlight]
    }); // [!code highlight]
  } catch (error) {
    const msg = error instanceof Error ? error.message.toLowerCase() : "";
    if (msg.includes("not found") || msg.includes("expired")) {
      return Response.json({
        success: true,
        email,
        note: "No active drip found (already completed or cancelled)",
      });
    }
    throw error;
  }

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

## How it works

1. **Durable sleep** — `sleep("2d")` persists through restarts at zero compute cost. The workflow resumes precisely when the timer fires.
2. **Hook creation** — `cancelDrip.create({ token })` registers a hook that resolves when any external system calls `.resume()` with the same token.
3. **Race** — `Promise.race([sleep(...), hook])` blocks until either the timer fires or the hook is resumed, whichever comes first.
4. **Fresh hooks per window** — after a sleep completes normally, the previous hook instance is consumed. A new `.create()` call registers a fresh hook for the next sleep window, reusing the same token.

<Callout type="info">
  Deterministic hook tokens can also serve as the idempotency point for scheduled runs. If duplicate schedule starts would send duplicate campaigns or reminders, create a hook with a token derived from the campaign key near the beginning of the workflow and route retries through that hook. If two scheduled starts race, the duplicate run can detect the conflict early with `await hook.getConflict()`, which resolves with the active owner so the duplicate can defer to it. See [Idempotency](/docs/foundations/idempotency).
</Callout>

## Adapting to your use case

* **Change durations** — replace `"2d"` with any duration string (`"1h"`, `"7d"`, `"30m"`) or a `Date` object for absolute times.
* **Add more steps** — the pattern scales to any number of email-then-sleep pairs.
* **Snooze instead of cancel** — resolve the hook with a `snooze` payload and sleep again: `sleep(new Date(Date.now() + payload.snoozeMs))`.
* **Timeout any operation** — the same `Promise.race(sleep, work)` pattern works for adding deadlines to slow steps.
* **Real providers** — swap the `sendEmail` step body for Resend, Postmark, or any HTTP API. The `"use step"` function has full Node.js access.

## Tips

* **`sleep()` accepts** duration strings (`"1d"`, `"2h"`, `"30s"`), milliseconds, or `Date` objects for sleeping until a specific time.
* **Durable means durable.** A `sleep("7d")` workflow costs nothing while sleeping — no compute, no memory.
* **Use `sleep()` in workflow context only.** Step functions cannot call `sleep()` directly. If a step needs a delay, use `setTimeout` inside the step.

## Key APIs

* [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function
* [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions that run with full Node.js access
* [`sleep()`](/docs/api-reference/workflow/sleep) — durable wait (survives restarts, zero compute cost)
* [`defineHook()`](/docs/api-reference/workflow/define-hook) — creates a typed hook that external systems can fire
* [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) — races sleep against hooks for interruptible waits


---
title: Sequential & Parallel Execution
description: Compose steps with familiar async/await patterns — sequential await, Promise.all, and Promise.race.
type: guide
summary: Workflows are just async functions, so all the standard composition primitives (await, Promise.all, Promise.race) apply unchanged — including racing webhooks against durable sleeps.
related:
  - /docs/foundations/workflows-and-steps
  - /cookbook/common-patterns/timeouts
  - /cookbook/common-patterns/scheduling
---

# Sequential & Parallel Execution



Workflows are written in plain async/await — there's no new control-flow API to learn. Sequential awaits chain steps that depend on each other, `Promise.all` runs independent steps in parallel, and `Promise.race` returns whichever finishes first. These compose with workflow primitives like [`sleep()`](/docs/api-reference/workflow/sleep) and [`createWebhook()`](/docs/api-reference/workflow/create-webhook) since those are also just promises.

## When to use this

* **Pipelines** — each step depends on the previous step's output (validate → process → store)
* **Independent fan-out** — fetch multiple resources or perform multiple actions that don't depend on each other
* **Race conditions** — return as soon as one of N operations completes (timeout, first-responder, deadline)
* **Mixing primitives** — running steps, sleeps, and webhooks side-by-side in the same control-flow expression

## Pattern

### Sequential

The simplest way to orchestrate steps is to execute them one after another, where each step depends on the previous step's output.

```typescript lineNumbers
declare function validateData(data: unknown): Promise<string>; // @setup
declare function processData(data: string): Promise<string>; // @setup
declare function storeData(data: string): Promise<string>; // @setup

export async function dataPipelineWorkflow(data: unknown) {
  "use workflow";

  const validated = await validateData(data);
  const processed = await processData(validated);
  const stored = await storeData(processed);

  return stored;
}
```

### Parallel with `Promise.all`

When steps don't depend on each other, run them concurrently with `Promise.all`. The workflow waits until all of them resolve.

```typescript lineNumbers
declare function fetchUser(userId: string): Promise<{ name: string }>; // @setup
declare function fetchOrders(userId: string): Promise<{ items: string[] }>; // @setup
declare function fetchPreferences(userId: string): Promise<{ theme: string }>; // @setup

export async function fetchUserData(userId: string) {
  "use workflow";

  const [user, orders, preferences] = await Promise.all([ // [!code highlight]
    fetchUser(userId), // [!code highlight]
    fetchOrders(userId), // [!code highlight]
    fetchPreferences(userId), // [!code highlight]
  ]); // [!code highlight]

  return { user, orders, preferences };
}
```

### Race with `Promise.race`

`Promise.race` resolves as soon as the first promise settles. Since [`sleep()`](/docs/api-reference/workflow/sleep) and [`createWebhook()`](/docs/api-reference/workflow/create-webhook) return promises, they compose naturally — for example, waiting for a webhook callback with a deadline:

```typescript lineNumbers
import { sleep, createWebhook } from "workflow";

declare function executeExternalTask(webhookUrl: string): Promise<void>; // @setup

export async function runExternalTask(userId: string) {
  "use workflow";

  const webhook = createWebhook();
  await executeExternalTask(webhook.url);

  await Promise.race([ // [!code highlight]
    webhook, // [!code highlight]
    sleep("1 day"), // [!code highlight]
  ]); // [!code highlight]

  console.log("Done");
}
```

For racing operations against deadlines specifically (timeouts), see the dedicated [Timeouts](/cookbook/common-patterns/timeouts) recipe — it covers result discrimination, `FatalError` semantics, and the "loser keeps running" caveat.

### Combining sequential, parallel, and durable primitives

Most real workflows combine all three. Here's a simplified version of the [birthday card generator demo](https://github.com/vercel/workflow-examples/tree/main/birthday-card-generator) — sequential card generation, parallel RSVP fan-out, non-blocking webhook collection, and a durable sleep until the birthday:

```typescript lineNumbers
import { createWebhook, sleep, type Webhook } from "workflow";

declare function makeCardText(prompt: string): Promise<string>; // @setup
declare function makeCardImage(text: string): Promise<string>; // @setup
declare function sendRSVPEmail(friend: string, webhook: Webhook): Promise<void>; // @setup
declare function sendBirthdayCard(text: string, image: string, rsvps: unknown[], email: string): Promise<void>; // @setup

export async function birthdayWorkflow(
  prompt: string,
  email: string,
  friends: string[],
  birthday: Date
) {
  "use workflow";

  const text = await makeCardText(prompt); // [!code highlight]
  const image = await makeCardImage(text); // [!code highlight]

  const webhooks = friends.map(() => createWebhook());

  await Promise.all( // [!code highlight]
    friends.map((friend, i) => sendRSVPEmail(friend, webhooks[i])) // [!code highlight]
  ); // [!code highlight]

  const rsvps: unknown[] = [];
  webhooks.map((webhook) =>
    webhook.then((req) => req.json()).then(({ rsvp }) => rsvps.push(rsvp))
  );

  await sleep(birthday); // [!code highlight]

  await sendBirthdayCard(text, image, rsvps, email);

  return { text, image, status: "Sent" };
}
```

## How it works

1. **`await` is durable.** When the workflow awaits a step, the runtime persists the step's input, suspends the workflow, runs the step, and replays the workflow with the step's result on resume. The same applies to `sleep()` and `createWebhook()`.
2. **`Promise.all` runs steps concurrently.** Each promise in the array is suspended on its own and the workflow resumes only when all have settled. Failures propagate — if any promise rejects, the whole `Promise.all` rejects.
3. **`Promise.race` resolves on the first settle.** The losing promises keep running in the background but their results are discarded by the workflow.
4. **All primitives are promises.** `sleep("1 day")` and `createWebhook()` return promises, so they compose with `Promise.all` / `Promise.race` exactly like steps do — this is what makes patterns like "race a webhook against a 24-hour deadline" a one-liner.

## Adapting to your use case

* **Replace `Promise.all` with `Promise.allSettled`** when partial failures should not abort the rest. You'll get an array of `{ status, value | reason }` instead of throwing on the first rejection.
* **Bound the parallelism** — `Promise.all` over 1000 items will fan out 1000 concurrent steps. If your downstream APIs can't handle that, batch the array into chunks (see [Batching](/cookbook/common-patterns/batching)).
* **Add a deadline to any race** — pair the operation with `sleep("30s").then(() => "timeout" as const)` and check the discriminated result. See [Timeouts](/cookbook/common-patterns/timeouts).
* **Mix steps and hooks in a race** — wait for an external signal *or* a deadline *or* a step result, all in the same `Promise.race`. The first one to resolve wins.

## Key APIs

* [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function
* [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions with full Node.js access
* [`sleep()`](/docs/api-reference/workflow/sleep) — durable sleep that survives restarts
* [`createWebhook()`](/docs/api-reference/workflow/create-webhook) — webhook URL the workflow can race against
* [`Promise.all()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) — wait for all promises
* [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) — wait for the first to settle
* [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) — wait for all, including failures


---
title: Timeouts
description: Add deadlines to slow operations by racing them against a durable sleep.
type: guide
summary: Use `Promise.race` with `sleep()` to bound the time any step, hook, or webhook is allowed to take — and recover gracefully when the deadline fires first.
related:
  - /docs/api-reference/workflow/sleep
  - /docs/foundations/hooks
  - /cookbook/common-patterns/scheduling
  - /cookbook/common-patterns/webhooks
---

# Timeouts



A common requirement is bounding how long a workflow waits for something to finish — a slow step, an external webhook, a human approval. Race the operation against a durable `sleep()` with `Promise.race()` — whichever finishes first wins, and the loser keeps running but its result is ignored.

## When to use this

* **Slow steps** — bound the time spent waiting on third-party APIs, model calls, or expensive computation
* **External callbacks** — give webhooks a deadline so the workflow doesn't hang forever waiting for an event that may never arrive
* **Human approvals** — auto-decline or escalate when a hook isn't resumed within a window
* **Polling loops** — give an outer poll-until-ready loop an overall budget

## Pattern

### Timeout on a slow step

```typescript lineNumbers
import { sleep } from "workflow";

declare function processData(data: string): Promise<string>; // @setup

export async function processWithTimeout(data: string) {
  "use workflow";

  const result = await Promise.race([ // [!code highlight]
    processData(data), // [!code highlight]
    sleep("30s").then(() => "timeout" as const), // [!code highlight]
  ]); // [!code highlight]

  if (result === "timeout") {
    throw new Error("Processing timed out after 30 seconds");
  }

  return result;
}
```

### Timeout on a webhook

The same pattern works for any promise — including hooks and webhooks. Here a webhook waits for an external service to call back, with a hard deadline of 7 days:

```typescript lineNumbers
import { sleep, createWebhook } from "workflow";

declare function sendApprovalRequest(requestId: string, webhookUrl: string): Promise<void>; // @setup

export async function waitForApproval(requestId: string) {
  "use workflow";

  const webhook = createWebhook<{ approved: boolean }>();
  await sendApprovalRequest(requestId, webhook.url);

  const result = await Promise.race([ // [!code highlight]
    webhook.then((req) => req.json()), // [!code highlight]
    sleep("7 days").then(() => ({ timedOut: true }) as const), // [!code highlight]
  ]); // [!code highlight]

  if ("timedOut" in result) {
    throw new Error("Approval request expired after 7 days");
  }

  // You may see warnings like `Workflow run completed with 1 uncommitted operations` in your
  // logs when the workflow completes. This is expected behavior.

  return result.approved;
}
```

## How it works

1. **Durable sleep** — `sleep("30s")` persists through restarts at zero compute cost. The workflow resumes precisely when the timer fires.
2. **Race** — `Promise.race([work, sleep(...)])` returns the value of whichever promise resolves first. The loser keeps running in the background but its result is ignored by the workflow.
3. **Discriminated result** — tagging the sleep branch with a sentinel value (`"timeout" as const`, `{ timedOut: true }`) lets TypeScript narrow the result and pick the right branch.
4. **Throw to fail the workflow** — inside a workflow function, throwing an `Error` exits the run with that error. Use `FatalError` inside steps; throw plain errors inside workflows.

<Callout type="warn">
  **The losing operation keeps running.** `Promise.race` doesn't cancel — when the sleep wins, the underlying step (or model call, or HTTP request) continues to completion in the background. This is fine for idempotent reads but matters when the operation has side effects or costs money. Use idempotency keys for non-idempotent side effects. For hard cancellation across processes, see [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller), and see [Idempotency](/docs/foundations/idempotency) for retry-safe side effects.
</Callout>

## Adapting to your use case

* **Different durations** — `sleep()` accepts duration strings (`"30s"`, `"5m"`, `"7 days"`), milliseconds, or `Date` objects for absolute deadlines.
* **Soft timeout (retry)** — instead of throwing, loop and retry with a fresh `Promise.race` and a backoff.
* **Soft timeout (fallback)** — return a default value when the timer wins instead of throwing: `if (result === "timeout") return cachedFallback`.
* **Combine with cancellation** — race three promises: the operation, a deadline `sleep()`, and a cancellation hook. See the [Scheduling cookbook](/cookbook/common-patterns/scheduling) for the cancellation half of this pattern.
* **Per-step deadlines** — wrap each step in its own `Promise.race` for independent budgets, or use a single outer race for an overall workflow deadline.

## Key APIs

* [`sleep()`](/docs/api-reference/workflow/sleep) — durable wait (survives restarts, zero compute cost)
* [`createWebhook()`](/docs/api-reference/workflow/create-webhook) — create a webhook URL the workflow can race against
* [`defineHook()`](/docs/api-reference/workflow/define-hook) — typed hook for in-process cancellation
* [Idempotency](/docs/foundations/idempotency) — protect side effects that may keep running after a timeout
* [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) — race operations against deadlines


---
title: Webhooks & External Callbacks
description: Receive HTTP callbacks from external services, process them durably, and respond inline.
type: guide
summary: Create webhook endpoints that your workflow can await, process incoming requests in steps, and respond to the caller — all within durable workflow context.
---

# Webhooks & External Callbacks



Use webhooks when external services push events to your application via HTTP callbacks. The workflow creates a webhook URL, suspends with zero compute cost, and resumes when a request arrives.

## When to use this

* Accepting callbacks from payment processors (Stripe, PayPal)
* Waiting for third-party verification or processing results
* Any integration where an external system calls you back asynchronously

## Pattern: Processing webhook events

Create a webhook with manual response control, then iterate over incoming requests:

```typescript
import { createWebhook, type RequestWithResponse } from "workflow";

declare function processEvent(request: RequestWithResponse): Promise<{ type: string }>; // @setup

export async function paymentWebhook(orderId: string) {
  "use workflow";

  const webhook = createWebhook({ respondWith: "manual" }); // [!code highlight]
  // webhook.url is the URL to give to the external service

  const ledger: { type: string }[] = [];

  for await (const request of webhook) { // [!code highlight]
    const entry = await processEvent(request);
    ledger.push(entry);

    // Break when we've received a terminal event
    if (entry.type === "payment.succeeded" || entry.type === "refund.created") {
      break;
    }
  }

  return { orderId, webhookUrl: webhook.url, ledger, status: "settled" };
}
```

### Step function for processing

Each webhook request is processed in its own step, giving you full Node.js access for validation, database writes, and responding to the caller:

```typescript
import { type RequestWithResponse } from "workflow";

async function processEvent(
  request: RequestWithResponse
): Promise<{ type: string }> {
  "use step";

  const body = await request.json().catch(() => ({}));
  const type = body?.type ?? "unknown";

  // Validate, process, and respond inline
  if (type === "payment.succeeded") {
    // Record the payment in your database
    await request.respondWith(Response.json({ ack: true, action: "captured" })); // [!code highlight]
  } else if (type === "payment.failed") {
    await request.respondWith(Response.json({ ack: true, action: "flagged" }));
  } else {
    await request.respondWith(Response.json({ ack: true, action: "ignored" }));
  }

  return { type };
}
```

## Pattern: Async request-reply with timeout

Submit a request to an external service, pass it your webhook URL, then race the callback against a deadline:

```typescript
import { createWebhook, sleep, FatalError, type RequestWithResponse } from "workflow";

export async function asyncVerification(documentId: string) {
  "use workflow";

  const webhook = createWebhook({ respondWith: "manual" });

  // Submit to vendor, passing our webhook URL for the callback
  await submitToVendor(documentId, webhook.url);

  // Race: wait for callback OR timeout after 30 seconds
  const result = await Promise.race([ // [!code highlight]
    (async () => {
      for await (const request of webhook) {
        const body = await processCallback(request);
        return body;
      }
      throw new FatalError("Webhook closed without callback");
    })(),
    sleep("30s").then(() => ({ status: "timed_out" as const })), // [!code highlight]
  ]);

  return { documentId, ...result };
}

async function submitToVendor(documentId: string, callbackUrl: string): Promise<void> {
  "use step";
  await fetch("https://vendor.example.com/verify", {
    method: "POST",
    body: JSON.stringify({ documentId, callbackUrl }),
  });
}

async function processCallback(
  request: RequestWithResponse
): Promise<{ status: string; details: string }> {
  "use step";
  const body = await request.json();
  await request.respondWith(Response.json({ ack: true }));
  return {
    status: body.approved ? "verified" : "rejected",
    details: body.details ?? body.reason ?? "",
  };
}
```

## Pattern: Large payload by reference

When payloads are too large to serialize into the event log, pass a lightweight reference (a "claim check") instead. Use a hook to signal when the data is ready:

```typescript
import { defineHook } from "workflow";

export const blobReady = defineHook<{ blobToken: string }>(); // [!code highlight]

export async function importLargeFile(importId: string) {
  "use workflow";

  // Suspend until the external system signals the blob is uploaded
  const { blobToken } = await blobReady.create({ token: `upload:${importId}` }); // [!code highlight]

  // Process by reference -- the full payload never enters the event log
  await processBlob(blobToken);

  return { importId, blobToken, status: "indexed" };
}

async function processBlob(blobToken: string): Promise<void> {
  "use step";
  // Fetch the blob using the token, process it
  const res = await fetch(`https://storage.example.com/blobs/${blobToken}`);
  const data = await res.arrayBuffer();
  // Index, transform, or store the data
}
```

Resume from an API route when the upload completes:

```typescript
import { resumeHook } from "workflow/api";

// POST /api/upload-complete
export async function POST(request: Request) {
  const { importId, blobToken } = await request.json();
  await resumeHook(`upload:${importId}`, { blobToken }); // [!code highlight]
  return Response.json({ ok: true });
}
```

## Tips

* **`respondWith: "manual"`** gives you control over the HTTP response from inside a step. Use this when you need to validate the request before responding.
* **`for await` on a webhook** lets you process multiple events from the same URL. Use `break` to stop listening after a terminal event.
* **Webhooks auto-generate URLs** at `/.well-known/workflow/v1/webhook/:token`. Pass this URL to external services.
* **Race webhooks against `sleep()`** for deadlines. If the callback doesn't arrive in time, the workflow can take a fallback action.
* **For large payloads**, use a hook + reference token instead of passing the data through the workflow. The event log serializes all step inputs/outputs, so large payloads hurt performance.

## Key APIs

* [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
* [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions with full Node.js access
* [`createWebhook()`](/docs/api-reference/workflow/create-webhook) -- creates an HTTP endpoint the workflow can await
* [`defineHook()`](/docs/api-reference/workflow/define-hook) -- creates a typed hook for signal-based patterns
* [`sleep()`](/docs/api-reference/workflow/sleep) -- durable timer for deadlines
* [`FatalError`](/docs/api-reference/workflow/fatal-error) -- prevents retry on permanent failures


---
title: Workflow Composition
description: Call workflows from other workflows by direct await (flatten into the parent) or background spawn via start() (separate run).
type: guide
summary: Compose workflows two ways — direct await flattens the child into the parent's event log, while background spawn via start() runs the child as an independent run.
related:
  - /cookbook/advanced/child-workflows
  - /cookbook/common-patterns/idempotency
  - /docs/api-reference/workflow-api/start
  - /docs/api-reference/workflow-api/get-run
---

# Workflow Composition



Workflows can call other workflows. Choose between two composition modes depending on whether the parent needs the child's result inline (direct await) or wants to fire the child off as an independent run (background spawn). For massive fan-out with hook-based waiting and partial-failure handling, see [Child Workflows](/cookbook/advanced/child-workflows).

## When to use this

* **Direct await** — the parent needs the child's result before continuing, and you want a single unified event log
* **Background spawn** — the parent doesn't need to wait, and you want the child to be observable as a separate run with its own `runId`

## Pattern

### Direct await (flattening)

Call a child workflow with `await` and the child's steps execute inline within the parent — they appear in the parent's event log as if you'd called them directly.

```typescript lineNumbers
declare function sendEmail(userId: string): Promise<void>; // @setup
declare function sendPushNotification(userId: string): Promise<void>; // @setup
declare function createAccount(userId: string): Promise<void>; // @setup
declare function setupPreferences(userId: string): Promise<void>; // @setup

// Child workflow
export async function sendNotifications(userId: string) {
  "use workflow";

  await sendEmail(userId);
  await sendPushNotification(userId);
  return { notified: true };
}

// Parent workflow calls the child directly
export async function onboardUser(userId: string) {
  "use workflow";

  await createAccount(userId);
  await sendNotifications(userId); // [!code highlight]
  await setupPreferences(userId);

  return { userId, status: "onboarded" };
}
```

The parent waits for the child to finish before continuing. Both functions share a single workflow run, a single retry boundary, and a single event log.

### Background spawn via `start()`

To run a child workflow independently without blocking the parent, call [`start()`](/docs/api-reference/workflow-api/start) from a step. This launches the child as a separate workflow run with its own `runId`.

```typescript lineNumbers
import { start } from "workflow/api";

declare function generateReport(reportId: string): Promise<void>; // @setup
declare function fulfillOrder(orderId: string): Promise<{ id: string }>; // @setup
declare function sendConfirmation(orderId: string): Promise<void>; // @setup

async function triggerReportGeneration(reportId: string) {
  "use step"; // [!code highlight]

  const run = await start(generateReport, [reportId]); // [!code highlight]
  return run.runId;
}

export async function processOrder(orderId: string) {
  "use workflow";

  const order = await fulfillOrder(orderId);

  const reportRunId = await triggerReportGeneration(orderId); // [!code highlight]

  await sendConfirmation(orderId);

  return { orderId, reportRunId };
}
```

The parent continues immediately after `start()` returns. The child runs independently and can be monitored separately using the returned `runId` (e.g., via [`getRun()`](/docs/api-reference/workflow-api/get-run)).

<Callout type="info">
  Each background spawn creates a separate run. If duplicate requests must route to one active child workflow, have the child create a deterministic hook token from the business key and use that hook as the idempotency point. If concurrent starts race, the losing child can detect the conflict early with `await hook.getConflict()`, which resolves with the active owner so the child can point callers at it. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
</Callout>

<Callout type="info">
  If you want the child workflow to run on the latest deployment rather than the current one, pass [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) in the `start()` options. See [Versioning](/docs/foundations/versioning) for the full model. This is currently a Vercel-specific feature, and other Worlds may map the concept to their own deployment runtimes. Be aware that the child workflow's function name, file path, argument types, and return type must remain compatible across deployments — renaming the function or changing its location will change the workflow ID, and modifying expected inputs or outputs can cause serialization failures.
</Callout>

## How it works

1. **Direct await flattens.** When a workflow function awaits another workflow function, the child's `"use workflow"` directive is treated as inline — the child's steps emit into the parent's event log and share the parent's run ID.
2. **`start()` mints a new run.** The child gets its own `runId`, its own event log, and its own retry boundary. The parent only sees the `runId` returned by `start()`.
3. **`start()` must be called from a step.** Calling `start()` directly from a workflow function is not allowed — wrap it in a `"use step"` function. This keeps the spawn deterministic across replays.

## Choosing between the two modes

|                            | Direct await                             | Background spawn (`start()`)               |
| -------------------------- | ---------------------------------------- | ------------------------------------------ |
| Parent waits for child     | Yes                                      | No                                         |
| Has its own `runId`        | No (shares parent's)                     | Yes                                        |
| Has its own event log      | No                                       | Yes                                        |
| Has its own retry boundary | No                                       | Yes                                        |
| Best for                   | Sequential composition, helper workflows | Independent work, fire-and-forget, fan-out |

## Adapting to your use case

* **Spawn many children at once** — call `start()` in a loop inside a step. For more advanced fan-out (chunking, hook-based waiting, partial-failure handling), graduate to the [Child Workflows](/cookbook/advanced/child-workflows) recipe.
* **Wait for a background child to finish** — combine `start()` with a completion hook the child resumes when done. The [Child Workflows](/cookbook/advanced/child-workflows) page covers the recommended `startAndWait()` pattern.
* **Pass results back from background children** — the wrapped child resumes the parent's hook in `finally` with `{ status, value | error }`; the parent awaits the hook instead of polling `getRun().status`.

## Key APIs

* [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function
* [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions with full Node.js access
* [`start()`](/docs/api-reference/workflow-api/start) — spawn a child workflow as a separate run
* [`getRun()`](/docs/api-reference/workflow-api/get-run) — retrieve a workflow run's status and return value
* [Idempotency](/docs/foundations/idempotency) — deduplicate step side effects and workflow starts


---
title: AI SDK
description: Use AI SDK's streamText directly inside durable workflows when you need the raw AI SDK API or a per-turn durability boundary.
type: guide
summary: Use streamText() inside a workflow when the durability boundary is an entire user turn, or when you need AI SDK APIs not exposed by WorkflowAgent. Individual tool calls and LLM calls inside a turn are not separately durable.
related:
  - /docs/ai
  - /docs/ai/chat-session-modeling
  - /docs/ai/defining-tools
  - /docs/ai/resumable-streams
  - /docs/api-reference/workflow-ai/durable-agent
---

# AI SDK



[AI SDK](https://ai-sdk.dev/) is Vercel's framework-agnostic TypeScript toolkit for building AI-powered apps and agents — unified provider access, streaming, tool calling, structured output, and UI hooks. Workflow SDK complements it by making the multi-turn loop durable: the conversation state, hooks, and per-turn responses survive restarts and timeouts. Note that in this pattern the durability boundary is the entire turn — individual tool calls inside a turn are **not** durable on their own (see [Pitfalls](#tools-are-not-individually-durable) below).

For the full AI SDK reference (providers, `streamText`, `generateObject`, `useChat`, tool calling, etc.) see the [AI SDK docs](https://ai-sdk.dev/docs). This page covers the Workflow-specific integration points.

<Callout type="info">
  For most agent use cases, prefer AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent), which implements the same agent loop as [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text), manages tool calling automatically, and runs tools at workflow scope — each tool can be marked `"use step"` for per-call durability and retries, or stay at workflow level to use primitives like `sleep()` and hooks. Use this page's raw `streamText()` pattern when you want the exact AI SDK API (for example `toUIMessageStream()`, `onChunk`, or `generateText`), or when the durability boundary should be an entire user turn in one step — accepting that tool calls inside that turn are not individually durable.
</Callout>

## When to use streamText directly

Use [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) instead of `WorkflowAgent` when you need:

* **The raw AI SDK API** — `streamText().toUIMessageStream()`, `onChunk`, `smoothStream`, or other options that map directly to the [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) return value rather than `WorkflowAgent.stream()`
* **Per-turn durability** — wrap the entire agent response (model + tools) in a single `"use step"` function so one user turn is the atomic retry unit; useful when you want all tool calls inside a turn to re-execute together
* **Custom multi-turn orchestration** — manual hook loops, per-turn stream slicing (`sliceUntilFinish`), or other workflow patterns shown below that don't map cleanly to `WorkflowAgent`

`WorkflowAgent` already supports `stopWhen`, `prepareStep`, lifecycle callbacks, structured output (`output`), per-step model switching, and [provider options](https://ai-sdk.dev/docs/ai-sdk-core/provider-options). See the [`WorkflowAgent` docs](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent).

## Multi-turn pattern

One workflow run = one full conversation. The workflow suspends between turns on a hook and resumes when the next user message arrives. Conversation state, tool history, and intermediate computation all live inside the run.

<Callout type="info">
  Because the conversation is one workflow run, it stays on the deployment that started it. If each turn should run on the latest deployment while preserving selected state or streams, see [Versioning](/docs/foundations/versioning) for the child-run continuation pattern.
</Callout>

<Tabs items={['Workflow', 'API Route', 'Client']}>
  <Tab value="Workflow">
    ```typescript title="workflows/support.ts" lineNumbers
    import { streamText, stepCountIs } from "ai";
    import { defineHook, getWritable, getWorkflowMetadata } from "workflow";
    import type { ModelMessage, UIMessageChunk } from "ai";
    import { z } from "zod";

    const MAX_TURNS = 20;

    export const turnHook = defineHook({ // [!code highlight]
      schema: z.object({ message: z.string() }),
    });

    // `streamText` runs tool executes inside `runTurn` (a step), so tool calls
    // are not individually durable — the entire turn retries together. See
    // "Tools are not individually durable" below. Make side-effectful tools idempotent.
    async function lookupOrder({ orderId }: { orderId: string }) {
      const res = await fetch(`https://api.store.com/orders/${orderId}`);
      return res.json();
    }

    async function processRefund({ orderId, reason }: { orderId: string; reason: string }) {
      const res = await fetch("https://api.store.com/refunds", {
        method: "POST",
        body: JSON.stringify({ orderId, reason }),
      });
      return res.json();
    }

    const TOOLS = {
      lookupOrder: {
        description: "Look up an order by ID",
        inputSchema: z.object({ orderId: z.string() }),
        execute: lookupOrder,
      },
      processRefund: {
        description: "Process a refund",
        inputSchema: z.object({ orderId: z.string(), reason: z.string() }),
        execute: processRefund,
      },
    };

    // Per-turn step — streams one agent response to the durable writable // [!code highlight]
    async function runTurn(messages: ModelMessage[]) {
      "use step";

      const result = streamText({
        model: "anthropic/claude-haiku-4.5",
        system: "You are a customer support agent.",
        messages,
        tools: TOOLS,
        stopWhen: stepCountIs(8),
      });

      const writable = getWritable<UIMessageChunk>();
      // preventClose keeps the durable writable open so the next turn can  // write to it. Each turn still emits its own start + finish chunks.
      await result.toUIMessageStream().pipeTo(writable, { preventClose: true }); // [!code highlight]

      const response = await result.response;
      return { responseMessages: response.messages };
    }

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

      const { workflowRunId } = getWorkflowMetadata();
      // Create the hook once, outside the loop — same token = HookConflictError // [!code highlight]
      const hook = turnHook.create({ token: workflowRunId }); // [!code highlight]
      let allMessages = initialMessages;

      for (let turn = 0; turn < MAX_TURNS; turn++) {
        const { responseMessages } = await runTurn(allMessages);
        allMessages = [...allMessages, ...responseMessages];

        const { message } = await hook; // [!code highlight] suspend until next user message
        if (message === "/done") break;

        allMessages = [...allMessages, { role: "user", content: message }];
      }

      return { turns: MAX_TURNS };
    }
    ```
  </Tab>

  <Tab value="API Route">
    One endpoint handles first turn, follow-ups, and the `/done` exit. The client sends `runId` in the body to distinguish first vs follow-up.

    <Callout type="info">
      The first turn calls `start()` and then returns the `runId`. If your client or platform can retry that first request before it receives and stores the `runId`, use an atomic conversation or request key before starting the workflow. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
    </Callout>

    ```typescript title="app/api/support/route.ts" lineNumbers
    import type { UIMessage, UIMessageChunk } from "ai";
    import { convertToModelMessages, createUIMessageStreamResponse } from "ai";
    import { start, getRun } from "workflow/api";
    import { supportWorkflow, turnHook } from "@/workflows/support";

    // Pump the durable stream until this turn's `finish` chunk, then close  // the HTTP response. The source reader is released (not cancelled) so the
    // workflow's durable stream keeps flowing for the next turn.
    function sliceUntilFinish( // [!code highlight]
      source: ReadableStream<UIMessageChunk>
    ): ReadableStream<UIMessageChunk> {
      return new ReadableStream<UIMessageChunk>({
        async start(controller) {
          const reader = source.getReader();
          try {
            while (true) {
              const { done, value } = await reader.read();
              if (done) break;
              controller.enqueue(value);
              if (value.type === "finish") break; // [!code highlight]
            }
            controller.close();
          } catch (e) {
            controller.error(e);
          } finally {
            reader.releaseLock();
          }
        },
      });
    }

    // `/done` exits the workflow without emitting chunks. Return a synthetic
    // start+finish so useChat's lifecycle terminates cleanly.
    function emptyTurnStream(): ReadableStream<UIMessageChunk> {
      return new ReadableStream<UIMessageChunk>({
        start(controller) {
          controller.enqueue({ type: "start", messageId: crypto.randomUUID() });
          controller.enqueue({ type: "finish" });
          controller.close();
        },
      });
    }

    export async function POST(req: Request) {
      const { messages, runId }: { messages: UIMessage[]; runId?: string } =
        await req.json();
      const modelMessages = await convertToModelMessages(messages);

      // Follow-up turn: resume hook, return stream starting AFTER the last turn // [!code highlight]
      if (runId) {
        try {
          const run = getRun(runId);

          // Snapshot tail before resuming so our slice only contains this turn // [!code highlight]
          const probe = run.getReadable();
          const tailIndex = await probe.getTailIndex();
          await probe.cancel();

          const lastUser = modelMessages.filter((m) => m.role === "user").at(-1);
          const text =
            typeof lastUser?.content === "string"
              ? lastUser.content
              : Array.isArray(lastUser?.content)
                ? lastUser.content
                    .filter((p): p is { type: "text"; text: string } =>
                      "type" in p && p.type === "text"
                    )
                    .map((p) => p.text)
                    .join("")
                : "";

          await turnHook.resume(runId, { message: text }); // [!code highlight]

          if (text === "/done") {
            return createUIMessageStreamResponse({
              stream: emptyTurnStream(),
              headers: { "x-workflow-run-id": runId },
            });
          }

          const stream = sliceUntilFinish(
            run.getReadable({ startIndex: tailIndex + 1 }) // [!code highlight]
          );

          return createUIMessageStreamResponse({
            stream,
            headers: { "x-workflow-run-id": runId },
          });
        } catch (e: unknown) {
          const msg = e instanceof Error ? e.message.toLowerCase() : "";
          if (!msg.includes("not found") && !msg.includes("expired")) throw e;
          // Stale runId — fall through to start fresh
        }
      }

      // First turn: start a new workflow // [!code highlight]
      const run = await start(supportWorkflow, [modelMessages]);
      const stream = sliceUntilFinish(run.readable);

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

  <Tab value="Client">
    Store the `runId` in a ref and pass it in the body of every follow-up. `WorkflowChatTransport` forwards it for you.

    ```tsx title="components/support-chat.tsx" lineNumbers
    "use client";

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

    export function SupportChat() {
      const [input, setInput] = useState("");
      const runIdRef = useRef<string | null>(null); // [!code highlight]

      const transport = useMemo(
        () =>
          new WorkflowChatTransport({
            api: "/api/support",
            prepareSendMessagesRequest: ({ messages, body }) => ({
              body: { ...body, messages, runId: runIdRef.current }, // [!code highlight]
            }),
            onChatSendMessage: (response) => {
              const id = response.headers.get("x-workflow-run-id");
              if (id) runIdRef.current = id; // [!code highlight]
            },
          }),
        []
      );

      const { messages, sendMessage, status } = useChat({ transport });
      const busy = status === "streaming" || status === "submitted";

      return (
        <form
          onSubmit={(e) => {
            e.preventDefault();
            if (busy || !input.trim()) return;
            sendMessage({ text: input });
            setInput("");
          }}
        >
          {messages.map((m) => (
            <div key={m.id}>{m.role}: {m.parts.map((p) => p.type === "text" ? p.text : "").join("")}</div>
          ))}
          <input value={input} onChange={(e) => setInput(e.target.value)} disabled={busy} />
        </form>
      );
    }
    ```
  </Tab>
</Tabs>

## How it works

1. **One workflow = one conversation.** The workflow loops on a hook, keeping `allMessages`, tool history, and state alive across turns.
2. **`runTurn` is the durability boundary.** Each turn is one step. The model request and all tool calls inside it run as plain inline functions within that step. If anything throws mid-turn, the whole `runTurn` retries — individual tool calls are not separately durable. See [Pitfalls](#tools-are-not-individually-durable).
3. **Hook is created once.** `turnHook.create({ token: workflowRunId })` outside the loop — calling it twice with the same token throws `HookConflictError`.
4. **`preventClose: true`** on `pipeTo` keeps the durable writable open so the next turn can write to it.
5. **`sliceUntilFinish`** in the API reads chunks until `type === "finish"`, then closes the HTTP response. The source reader is released — not cancelled — so the workflow stream keeps flowing.
6. **`startIndex: tailIndex + 1`** gives each follow-up response only the new chunks, avoiding replay of previous turns.
7. **`/done`** resumes the hook so the workflow exits cleanly, then returns a synthetic `start` + `finish` so `useChat` transitions out of "streaming".

## Pitfalls

Non-obvious correctness details worth knowing before adapting this pattern.

### Tools are not individually durable

`streamText()` is invoked from inside `runTurn` (a `"use step"` function), and the AI SDK calls each tool by directly invoking its `execute` function in that same step. Even if a tool body has its own `"use step"` directive, that directive is a [no-op when called from another step](/docs/foundations/workflows-and-steps#step-functions) — the function just runs inline.

The consequences:

* The atomic retry unit is the entire `runTurn`, not the individual tool call.
* If `processRefund` succeeds and then the model call (or a later tool) throws, the whole turn retries, and `processRefund` will run again.
* Tool calls do not appear as separate entries in the event log or observability dashboard.

**Mitigations:**

* Make side-effectful tool implementations idempotent — dedupe server-side on a stable key (e.g. `orderId`, an `Idempotency-Key` header, etc.).
* Or use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent), which runs tools at workflow scope — each tool can be marked `"use step"` to become its own durable, retryable step, or stay at workflow level to use primitives like `sleep()` and hooks.

### Snapshot `tailIndex` *before* resuming the hook

{/* @skip-typecheck - fragment referencing variables from the surrounding multi-turn pattern */}

```typescript
const tailIndex = await probe.getTailIndex(); // [!code highlight] FIRST
await probe.cancel();
await turnHook.resume(runId, { message: text }); // [!code highlight] THEN
const stream = run.getReadable({ startIndex: tailIndex + 1 });
```

Reversing the order races the workflow: by the time you read `tailIndex`, the next turn has already written its `start` chunk, and your `startIndex + 1` skips past it.

### Don't call `writable.close()` inside a workflow function

I/O operations like closing streams must happen inside a `"use step"` function. Calling `writable.close()` directly in the workflow body throws `Not supported in workflow functions`. When the workflow returns, the runtime closes the underlying writable for you.

### Don't use `TransformStream.terminate()` to slice the stream

A `TransformStream` with `controller.terminate()` on the `finish` chunk seems like the obvious fit for `sliceUntilFinish`, but throws `Invalid state: TransformStream has been terminated` when late-arriving chunks hit the transform callback. Manual pumping through a custom `ReadableStream` (as shown above) sidesteps the problem entirely.

### Release the source reader, don't cancel it

In `sliceUntilFinish`, use `reader.releaseLock()` in the `finally` block rather than `source.cancel()`. Cancelling propagates upstream and closes the durable writable, breaking the next turn. Releasing the lock just detaches our reader; the durable stream keeps flowing.

### Handle stale `runId` gracefully

Clients can send a `runId` from a long-gone workflow (localStorage, back button, server restart). Wrap the follow-up path in a try/catch for `not found` / `expired` and fall through to the first-turn code path to start a fresh workflow.

### Make the first turn idempotent when needed

This example stores the `runId` after the first response. For strict one-session-per-thread behavior, use a deterministic hook token derived from the thread ID or conversation ID and route retries through the active hook. See [Idempotency](/docs/foundations/idempotency).

## streamText vs WorkflowAgent

|                          | `streamText()` (this pattern)                               | `WorkflowAgent`                                                                                             |
| ------------------------ | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| **Tool loop**            | AI SDK handles via `stopWhen`                               | Handles internally (AI SDK–compatible options)                                                              |
| **LLM call durability**  | Re-executes with the parent turn                            | Each LLM call is a durable step                                                                             |
| **Tool call durability** | Not individually durable — re-executes with the parent turn | Per tool — mark `"use step"` for a durable, retryable step, or keep at workflow level for `sleep()` / hooks |
| **Stop conditions**      | `stopWhen`, `prepareStep`                                   | `stopWhen`, `prepareStep`                                                                                   |
| **Structured output**    | `Output.object()`, `Output.array()`                         | `output` (`Output.object()`, `Output.text()`)                                                               |
| **Step callbacks**       | `onStepFinish`, `onChunk`, etc.                             | `onStepFinish`, `onFinish`, `onError`, `onAbort` (`onChunk` not available)                                  |
| **Setup**                | Manual stream piping and turn slicing                       | Automatic                                                                                                   |

Use `WorkflowAgent` for most agent use cases. Use `streamText` when you need the raw AI SDK surface or a per-turn durability boundary.

## Key APIs

**AI SDK** ([docs](https://ai-sdk.dev/docs))

* [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) — core streaming function; `toUIMessageStream()` pipes into the durable writable
* [`tool()` / tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) — tools are plain async functions invoked by `streamText` inside the turn step; they are **not** individually durable in this pattern (see [Pitfalls](#tools-are-not-individually-durable))
* [`stepCountIs()` / `stopWhen`](https://ai-sdk.dev/docs/ai-sdk-core/agents#stop-conditions) — bound the agent loop inside each turn
* [`convertToModelMessages()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/convert-to-model-messages) / [`createUIMessageStreamResponse()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/create-ui-message-stream-response) — UI ↔ model message conversion at the API boundary
* [`useChat()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) — React hook that consumes the UI message stream on the client

**Workflow SDK**

* [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — applied to `runTurn` to make each turn a durable, retryable unit
* [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for follow-up messages
* [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable stream output
* [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.getReadable({ startIndex })` for slicing per-turn streams
* [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport) — passes `runId` between turns
* [Idempotency](/docs/foundations/idempotency) — protect duplicate-sensitive first turns and side effects


---
title: Chat SDK
description: Make Chat SDK bot sessions durable — one workflow run per conversation thread, with hooks bridging inbound platform events into long-running agent logic.
type: guide
summary: Chat SDK normalizes Slack, Teams, Discord, Telegram and friends into one thread/message model. Workflow SDK gives each thread a durable run that owns multi-turn state, can sleep for hours, and survives restarts.
related:
  - /cookbook/integrations/ai-sdk
  - /cookbook/integrations/sandbox
  - /docs/api-reference/workflow/define-hook
  - /docs/api-reference/workflow-api/start
  - /docs/api-reference/workflow-api/get-run
---

# Chat SDK



[Chat SDK](https://chat-sdk.dev/) is a unified TypeScript SDK for building bots across Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. Write the bot once, deploy to every platform. It handles webhook verification, event normalization, subscriptions, and cross-platform features like cards and modals.

Workflow SDK complements it by making bot **sessions** durable. Each conversation thread maps to a long-running workflow run that:

* Owns multi-turn state in the durable event log instead of Redis-by-hand bookkeeping
* Can `sleep()` for hours or days waiting for a user reply, an approval, or a scheduled follow-up
* Survives deploys, cold starts, and crashes — the session picks up from the last step on replay
* Receives follow-up messages via hooks, so the bot stays responsive while the workflow is still running

<Callout type="info">
  One thread mapped to one workflow run also means the thread stays on the deployment that started it. For channels where each message should use newer code, see [Versioning](/docs/foundations/versioning) for explicit child-run and handoff patterns using `deploymentId: "latest"`.
</Callout>

The rest of this page covers the integration pattern. For a full Slack + Next.js + Redis walkthrough, see the [Durable chat sessions guide](https://chat-sdk.dev/docs/guides/durable-chat-sessions-nextjs) on chat-sdk.dev.

## How It Fits Together

Chat SDK owns the edge — webhook verification, event routing, `thread.post()` / `thread.stream()`. Workflow owns the session — state, loops, sleeps, retries. They meet at exactly two points:

<Mermaid
  chart="flowchart TD
    A[&#x22;Platform webhook&#x22;] --> B[&#x22;Chat SDK event handler<br/>(onNewMention, onSubscribedMessage, …)&#x22;]
    B -->|&#x22;no runId in thread state&#x22;| C[&#x22;start(durableChatSession, …)&#x22;]
    B -->|&#x22;runId in thread state&#x22;| D[&#x22;resumeHook(runId, { message })&#x22;]
    C --> E[&#x22;Workflow run (durable)<br/>one per thread; suspends between turns&#x22;]
    D --> E
    E --> F[&#x22;&quot;use step&quot; helpers<br/>thread.post(), thread.subscribe(), thread.setState(), …&#x22;]"
/>

* **Inbound** — Chat SDK handlers decide whether to `start(workflow, [thread, message])` or `resumeHook(runId, { message })`. The `runId` lives in Chat SDK's thread state (Redis, Postgres, or any state adapter).
* **Outbound** — the workflow calls Chat SDK APIs (`thread.post()`, `thread.subscribe()`, `thread.setState()`) from inside step functions. Never from the top level of a workflow file — adapter packages use Node-only modules that aren't available in the workflow sandbox.

## Why Workflow + Chat SDK

Without Workflow, a long-running bot session usually means one of:

* Holding a webhook request open while the agent runs (doesn't survive restarts, blows past platform timeouts)
* Writing session state to Redis manually, plus a scheduler for timeouts and retries, plus custom reconnection logic

Workflow replaces all of that with a single durable function. The bot can:

* Run a tool loop for minutes while the user watches typing indicators
* Wait for a human approval in another thread before continuing
* Schedule a follow-up message 24 hours later via `sleep("24h")`
* Pause on sandbox snapshot, resume when the user sends the next command (see the [Sandbox integration](/cookbook/integrations/sandbox))

Because the session *is* a workflow run, its history is recoverable from the event log — no separate message store to keep in sync.

## The Pattern: One Thread = One Workflow Run

Three files. The bot definition is separate from the workflow so adapter packages stay out of the workflow sandbox.

<Tabs items={['Bot Setup', 'Workflow', 'Event Handlers']}>
  <Tab value="Bot Setup">
    Register the `Chat` instance as a singleton so step functions can dynamically import it and resolve adapters + state:

    ```typescript title="lib/bot.ts" lineNumbers
    import { Chat } from "chat";
    import { createSlackAdapter } from "@chat-adapter/slack";
    import { createRedisState } from "@chat-adapter/state-redis";

    const adapters = {
      slack: createSlackAdapter(),
    };

    export interface ThreadState {
      runId?: string; // [!code highlight]
    }

    export const bot = new Chat<typeof adapters, ThreadState>({
      userName: "durable-bot",
      adapters,
      state: createRedisState(),
      dedupeTtlMs: 600_000,
    }).registerSingleton(); // [!code highlight]
    ```

    `registerSingleton()` is important: Chat SDK re-hydrates `Thread` objects inside step functions, and it needs a registered singleton to resolve adapters and state for those rehydrated instances.
  </Tab>

  <Tab value="Workflow">
    The workflow is a plain loop over a hook. It receives the serialized thread + first message from the handler, revives them via Chat SDK's standalone `reviver`, and every platform-side effect goes inside a `"use step"` helper:

    ```typescript title="workflows/durable-chat-session.ts" lineNumbers
    import { Message, reviver, type Thread } from "chat";
    import { defineHook, getWorkflowMetadata } from "workflow";
    import type { ThreadState } from "@/lib/bot";

    // Hook payload lives in its own file so the webhook side can import it without
    // pulling in the workflow module.
    import type { ChatTurnPayload } from "@/workflows/chat-turn-hook";

    const chatTurnHook = defineHook<ChatTurnPayload>(); // [!code highlight]

    async function postAssistantMessage(
      thread: Thread<ThreadState>,
      text: string
    ) {
      "use step";
      // Dynamic import keeps adapter packages out of the workflow sandbox.
      const { bot } = await import("@/lib/bot"); // [!code highlight]
      await bot.initialize();
      await thread.post(text);
    }

    async function runTurn(text: string) {
      "use step";
      // Your AI SDK call, database lookup, tool loop, etc.
      return `You said: ${text}`;
    }

    async function handleMessage(
      thread: Thread<ThreadState>,
      message: Message
    ) {
      const text = message.text.trim();
      if (text.toLowerCase() === "done") return false;

      const reply = await runTurn(text);
      await postAssistantMessage(thread, reply);
      return true;
    }

    export async function durableChatSession(payload: string) {
      "use workflow";

      const { workflowRunId } = getWorkflowMetadata();
      const { thread, message } = JSON.parse(payload, reviver) as { // [!code highlight]
        thread: Thread<ThreadState>;
        message: Message;
      };

      const hook = chatTurnHook.create({ token: workflowRunId });

      await postAssistantMessage(thread, "Session started. Reply here; send `done` to stop.");

      if (!(await handleMessage(thread, message))) return;

      // Each hook resumption is one turn. The workflow stays suspended between
      // messages — zero compute cost while idle.
      while (true) {
        const { message: nextRaw } = await hook; // [!code highlight]
        const next = Message.fromJSON(nextRaw);
        if (!(await handleMessage(thread, next))) return;
      }
    }
    ```

    ```typescript title="workflows/chat-turn-hook.ts" lineNumbers
    import type { SerializedMessage } from "chat";

    export type ChatTurnPayload = {
      message: SerializedMessage;
    };
    ```
  </Tab>

  <Tab value="Event Handlers">
    Handlers live outside the workflow file so adapter dependencies don't leak in. They decide whether to start a new workflow or resume an existing one, then store the `runId` in thread state:

    <Callout type="info">
      If the platform can deliver the same first message concurrently, use a deterministic hook token derived from the thread ID so duplicate handlers route to the active chat session hook. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
    </Callout>

    ```typescript title="lib/chat-session-handlers.ts" lineNumbers
    import type { Message, Thread } from "chat";
    import { getRun, resumeHook, start } from "workflow/api";
    import { bot, type ThreadState } from "@/lib/bot";
    import { durableChatSession } from "@/workflows/durable-chat-session";
    import type { ChatTurnPayload } from "@/workflows/chat-turn-hook";

    async function startSession(thread: Thread<ThreadState>, message: Message) {
      const run = await start(durableChatSession, [ // [!code highlight]
        JSON.stringify({
          thread: thread.toJSON(),
          message: message.toJSON(),
        }),
      ]);
      await thread.setState({ runId: run.runId });
    }

    async function routeTurn(thread: Thread<ThreadState>, message: Message) {
      const state = await thread.state;

      // No run yet, or the previous run finished — start fresh.
      if (!state?.runId || !(await getRun(state.runId).exists)) {
        await startSession(thread, message);
        return;
      }

      try {
        await resumeHook<ChatTurnPayload>(state.runId, { // [!code highlight]
          message: message.toJSON(),
        });
      } catch (err) {
        const msg = err instanceof Error ? err.message.toLowerCase() : "";
        if (msg.includes("not found") || msg.includes("expired")) {
          // Stale runId — start a new session rather than dropping the message.
          await startSession(thread, message);
          return;
        }
        throw err;
      }
    }

    bot.onNewMention(async (thread, message) => {
      await thread.subscribe();
      await routeTurn(thread, message);
    });

    bot.onSubscribedMessage(async (thread, message) => {
      await routeTurn(thread, message);
    });
    ```

    Wire Chat SDK's webhook handler into a catch-all route. Importing `chat-session-handlers` for side effects registers the event handlers before the first webhook arrives:

    ```typescript title="app/api/webhooks/[platform]/route.ts" lineNumbers
    import "@/lib/chat-session-handlers";
    import { after } from "next/server";
    import { bot } from "@/lib/bot";

    type Platform = keyof typeof bot.webhooks;

    export async function POST(
      req: Request,
      { params }: { params: Promise<{ platform: string }> }
    ) {
      const { platform } = await params;
      const handler = bot.webhooks[platform as Platform];
      if (!handler) return new Response(`Unknown platform: ${platform}`, { status: 404 });

      return handler(req, { waitUntil: (task) => after(() => task) }); // [!code highlight]
    }
    ```
  </Tab>
</Tabs>

## How It Works

1. **Thread state stores the `runId`.** Chat SDK's state adapter (Redis, Postgres, memory) holds `{ runId }` per thread. That's the only piece of glue between the two SDKs.
2. **First mention → `start()`.** Handler serializes `thread` + `message` with `toJSON()`, passes them through `start(durableChatSession, [payload])`, stashes the returned `runId` in thread state.
3. **Subsequent messages → `resumeHook()`.** Handler looks up the `runId`, serializes the new message, and resumes the workflow's hook. The workflow picks up on the next `await hook` iteration.
4. **Workflow posts back via steps.** All Chat SDK side effects (`thread.post`, `thread.subscribe`, `thread.setState`) happen inside `"use step"` helpers that dynamically import the bot. This keeps adapter packages outside the workflow sandbox.
5. **Session ends — two ways.** The workflow returns normally (user said `done`, approval granted, etc.), or the workflow throws. Either way the run completes; the next inbound message with the stale `runId` falls through to `startSession()`.

The workflow is fully durable between turns: `await hook` suspends with zero compute cost, and platform webhooks can fire from anywhere without concern for which server instance handled the previous turn.

## Extending the Pattern

Because the session is just a workflow, everything else from the cookbook composes naturally:

* **Stream AI SDK responses into the thread.** Use the [AI SDK integration](/cookbook/integrations/ai-sdk) pattern inside a step, then pass `result.fullStream` to `thread.post()` — Chat SDK handles platform-specific streaming (Slack edit-in-place, Telegram message-per-chunk, etc.).
* **Give the bot a sandbox.** Combine with the [Sandbox integration](/cookbook/integrations/sandbox): each thread gets its own persistent sandbox session, snapshots on idle, resumes on the next message. That's effectively a coding-agent bot.
* **Human-in-the-loop approvals.** `Promise.race([hook, approvalHook])` inside the workflow, post buttons in the thread via [cards](https://chat-sdk.dev/docs/cards), resume `approvalHook` from `bot.onAction(...)`.
* **Scheduled follow-ups.** `sleep("24h")` before a proactive check-in. Surviving restarts is free.

## Pitfalls

### Don't import the bot at the top of workflow files

Adapter packages (`@chat-adapter/slack`, `@chat-adapter/telegram`, etc.) depend on Node-only modules that aren't available in the workflow bundler's sandbox. Keep `import { bot } from "@/lib/bot"` inside `"use step"` functions with `await import(...)`. Use `reviver` from `chat` for deserialization inside the workflow — it's standalone and has no adapter dependencies.

### Register the bot as a singleton

`new Chat({...}).registerSingleton()`. Chat SDK rehydrates `Thread` objects inside step functions via `reviver`, and it looks up adapters + state from the registered singleton. Without it, thread methods throw when called from step contexts.

### Hook payloads must be JSON-serializable

`Message` and `Thread` have methods, so pass them through `.toJSON()` / `Message.fromJSON()` across the hook boundary. Define a `ChatTurnPayload` type in its own file so both the webhook handler (in the Node bundle) and the workflow (in the workflow sandbox) can share it without dragging in adapter code.

### Handle stale `runId`s

A workflow run ends but its `runId` is still cached in thread state. The next message calls `resumeHook` on a dead run and throws `not found` / `expired`. Gate on `getRun(runId).exists` before resuming, or catch the error and fall through to `startSession`. Either way the user's message must not be dropped.

### Make first-message routing atomic

Thread state is also the idempotency boundary for starting sessions. Back it with a state adapter or database operation that can atomically claim the thread before `startSession()` runs when duplicate sessions would be harmful.

### Keep the hook outside the loop

One `chatTurnHook.create({ token: workflowRunId })` per workflow run, reused every iteration. Creating a new hook with the same token throws `HookConflictError`. This is the same rule as the [AI SDK](/cookbook/integrations/ai-sdk) and [Sandbox](/cookbook/integrations/sandbox) session patterns.

### Platform timeouts are separate from workflow timeouts

Slack wants a 200 within 3 seconds. The webhook handler returns immediately after `resumeHook` (which is fast) — the workflow then runs in the background and posts back via `thread.post`. Don't try to `await` the whole turn inside the webhook handler; that's what breaks in the naive integration.

## Key APIs

* [`Chat`](https://chat-sdk.dev/docs/api/chat) / [`Thread`](https://chat-sdk.dev/docs/api/thread) / [`Message`](https://chat-sdk.dev/docs/api/message) — Chat SDK primitives. `toJSON()` / `fromJSON()` / `reviver` are the serialization layer.
* [`start()`](/docs/api-reference/workflow-api/start) — start a new session workflow. Store the returned `runId` in thread state.
* [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) — forward a new platform message to the running workflow.
* [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.exists` before resuming, to detect stale `runId`s.
* [`defineHook()`](/docs/api-reference/workflow/define-hook) — per-turn suspension point inside the workflow.
* [`registerSingleton()`](https://chat-sdk.dev/docs/api/chat) — makes the bot resolvable from inside step functions.
* [Idempotency](/docs/foundations/idempotency) — protect duplicate-sensitive first messages and side effects.


---
title: Sandbox
description: Model one Vercel Sandbox per workflow run — durable, idle-efficient, and not bound by the 5-hour sandbox hard cap.
type: guide
summary: Own a sandbox for the lifetime of a workflow run. Hibernate on idle via snapshot(), proactively refresh before the sandbox hard cap, and reconnect by runId — so one logical session can run effectively forever.
related:
  - /docs/ai/defining-tools
  - /docs/foundations/errors-and-retries
  - /cookbook/common-patterns/scheduling
  - /cookbook/agent-patterns/durable-agent
---

# Sandbox



[Vercel Sandbox](https://vercel.com/docs/sandbox) provides isolated code execution environments. The `@vercel/sandbox` package has first-class support for the Workflow SDK — the `Sandbox` class is serializable, and its methods (`create`, `runCommand`, `stop`, `snapshot`) implicitly run as steps. You can use `Sandbox` directly inside a workflow function without wrapping each call in a separate `"use step"` function.

## Why Workflow + Sandbox

A sandbox alone gets you an isolated VM. A workflow around it gets you a **durable controller** for that VM's entire lifetime:

* **One workflow run = one sandbox session.** The `runId` is the only state you need to persist on the client. Close the tab, come back a week later, POST the same `runId` and you're back in the same session.
* **Efficient resource use.** Active sandboxes cost money; hibernated workflows cost nothing. The workflow races a command hook against a `sleep()` timer — when idle, it calls `sandbox.snapshot()` (which also stops the VM) and waits indefinitely. Next command → spin a new sandbox from the snapshot with filesystem, installed packages, and git history intact.
* **Beyond the 5-hour hard cap.** Every Vercel Sandbox has a maximum lifetime. The workflow tracks that deadline and proactively snapshots + recreates *before* the cap, so the logical session outlives any one VM. Effectively unbounded session duration on top of time-bounded infrastructure.
* **Automatic cleanup.** `try/finally` in the workflow guarantees the VM is stopped on failure or destroy.

<Callout type="info">
  An effectively unbounded sandbox session is still one workflow run, so it stays on the deployment that started it. If the controller or agent code should upgrade over time, use an explicit version boundary and pass the serialized state or stream handles forward. See [Versioning](/docs/foundations/versioning).
</Callout>

## Use Case: Coding Agents

This is the pattern [Open Agents](https://open-agents.dev/) uses to spawn coding agents that run "infinitely in the cloud." Each agent session gets its own sandbox — full filesystem, network, and runtime access — and the durable workflow keeps the agent loop resumable across restarts, auto-hibernates when the user walks away, and reconnects instantly when they return.

Most coding-agent workloads look like this:

* User sends a task → agent plans, reads files, runs shell commands, commits.
* User walks away mid-run → agent keeps going, eventually goes idle waiting for input.
* User comes back days later → same branch, same filesystem, same conversation history.

Without durable workflows you'd need a separate state store for the agent loop, a separate job queue for retries, a separate scheduler for idle cleanup, and bespoke reconnection logic. With the pattern below, all of it is one file.

## Quickstart: One-shot Pipeline

Before the full session pattern, the simplest shape. Each sandbox method is an implicit step, so the event log records every command and the workflow replays from the last completed call on restart.

```typescript title="workflows/sandbox-pipeline.ts" lineNumbers
import { Sandbox } from "@vercel/sandbox";

export async function sandboxPipeline(input: { commands: string[] }) {
  "use workflow";

  const sandbox = await Sandbox.create({ runtime: "node22" }); // [!code highlight]

  try {
    const results = [];
    for (const command of input.commands) {
      const result = await sandbox.runCommand({ // [!code highlight]
        cmd: "bash",
        args: ["-c", command],
      });
      results.push({
        command,
        exitCode: result.exitCode,
        stdout: await result.stdout(),
        stderr: await result.stderr(),
      });
    }
    return { status: "completed", results };
  } finally {
    await sandbox.stop(); // [!code highlight]
  }
}
```

## Session Pattern: Persistent Sandbox Beyond the Hard Cap

One workflow run owns a sandbox for its whole lifetime. The workflow's loop does two jobs simultaneously:

1. **Command pipeline** — await a hook, run the next user command, stream output, loop.
2. **Sandbox lifecycle** — race the hook against a `sleep()` timer armed for whichever comes first: the idle deadline or the sandbox's refresh deadline (a safety margin before its hard cap).

When the timer wins:

* **Idle** → `sandbox.snapshot()` and wait indefinitely for the next command. No compute while asleep.
* **Near sandbox hard cap** → `sandbox.snapshot()` and immediately create a new sandbox from the snapshot. The session appears continuous; the underlying VM just rotated.

The only way out is an explicit `/destroy` command.

<Tabs items={['Workflow', 'API Routes', 'Client']}>
  <Tab value="Workflow">
    ```typescript title="workflows/sandbox-session.ts" lineNumbers
    import { defineHook, sleep, getWritable, getWorkflowMetadata } from "workflow";
    import { Sandbox, type Snapshot } from "@vercel/sandbox";
    import { z } from "zod";

    export const commandHook = defineHook({ // [!code highlight]
      schema: z.object({ command: z.string() }),
    });

    const RUNTIME = "node22";
    const HIBERNATE_AFTER_MS = 30 * 60_000; // 30 min idle → hibernate
    const SANDBOX_TIMEOUT_MS = 5 * 60 * 60_000; // sandbox hard cap (5h)
    const REFRESH_SAFETY_MS = 5 * 60_000; // refresh 5 min before the cap

    export type SandboxEvent =
      | {
          type: "created";
          sandboxId: string;
          runtime: string;
          startedAt: number;
          sandboxExpiresAt: number;
          hibernateAfterMs: number;
        }
      | {
          type: "status";
          state:
            | "active"
            | "hibernating"
            | "hibernated"
            | "resuming"
            | "refreshing"
            | "destroyed";
          at: number;
          sandboxId?: string;
          sandboxExpiresAt?: number;
          snapshotId?: string;
        }
      | { type: "activity"; at: number }
      | { type: "command_start"; id: string; command: string; at: number }
      | { type: "command_output"; id: string; stream: "stdout" | "stderr"; data: string }
      | { type: "command_end"; id: string; exitCode: number | null; durationMs: number }
      | { type: "result"; status: "destroyed"; durationMs: number };

    async function emit(event: SandboxEvent) {
      "use step";
      const writer = getWritable<SandboxEvent>().getWriter();
      try {
        await writer.write(event);
      } finally {
        writer.releaseLock();
      }
    }

    async function runCommandAndStream(sandbox: Sandbox, id: string, command: string) {
      "use step";
      const writer = getWritable<SandboxEvent>().getWriter();
      const startedAt = Date.now();
      try {
        await writer.write({ type: "command_start", id, command, at: startedAt });
        const result = await sandbox.runCommand({ cmd: "bash", args: ["-c", command] });
        const stdout = await result.stdout();
        if (stdout) await writer.write({ type: "command_output", id, stream: "stdout", data: stdout });
        const stderr = await result.stderr();
        if (stderr) await writer.write({ type: "command_output", id, stream: "stderr", data: stderr });
        await writer.write({
          type: "command_end", id,
          exitCode: result.exitCode,
          durationMs: Date.now() - startedAt,
        });
      } finally {
        writer.releaseLock();
      }
    }

    export async function sandboxSessionWorkflow() {
      "use workflow";

      const { workflowRunId } = getWorkflowMetadata();
      // Create the hook once, outside the loop — reusing the same token from inside // [!code highlight]
      // the loop would throw HookConflictError. // [!code highlight]
      const hook = commandHook.create({ token: workflowRunId });

      const startedAt = Date.now();

      let sandbox: Sandbox = await Sandbox.create({
        runtime: RUNTIME,
        timeout: SANDBOX_TIMEOUT_MS,
      });
      let sandboxCreatedAt = Date.now();
      let sandboxExpiresAt = sandboxCreatedAt + SANDBOX_TIMEOUT_MS;

      await emit({
        type: "created",
        sandboxId: sandbox.sandboxId,
        runtime: RUNTIME,
        startedAt,
        sandboxExpiresAt,
        hibernateAfterMs: HIBERNATE_AFTER_MS,
      });
      await emit({
        type: "status", state: "active", at: Date.now(),
        sandboxId: sandbox.sandboxId, sandboxExpiresAt,
      });

      let snapshot: Snapshot | null = null;
      let hibernated = false;
      let lastActivityAt = startedAt;
      let counter = 0;
      let destroyed = false;

      try {
        while (!destroyed) {
          if (hibernated && snapshot) {
            // While hibernated, the VM is already stopped. Just wait for the next
            // command — no idle timer, no compute cost.
            const payload = await hook;
            if (payload.command === "/destroy") { destroyed = true; break; }

            await emit({ type: "status", state: "resuming", at: Date.now() });
            sandbox = await Sandbox.create({ // [!code highlight]
              source: { type: "snapshot", snapshotId: snapshot.snapshotId }, // [!code highlight]
              timeout: SANDBOX_TIMEOUT_MS, // [!code highlight]
            });
            sandboxCreatedAt = Date.now();
            sandboxExpiresAt = sandboxCreatedAt + SANDBOX_TIMEOUT_MS;
            hibernated = false;
            snapshot = null;
            await emit({
              type: "status", state: "active", at: Date.now(),
              sandboxId: sandbox.sandboxId, sandboxExpiresAt,
            });

            counter += 1;
            await runCommandAndStream(sandbox, `cmd-${counter}`, payload.command);
            lastActivityAt = Date.now();
            await emit({ type: "activity", at: lastActivityAt });
            continue;
          }

          // Active — wake at whichever comes first: idle-deadline or refresh-deadline.
          const idleDeadline = lastActivityAt + HIBERNATE_AFTER_MS;
          const refreshDeadline = sandboxExpiresAt - REFRESH_SAFETY_MS;
          const wakeAt = Math.min(idleDeadline, refreshDeadline);
          const sleepMs = Math.max(0, wakeAt - Date.now());

          const outcome = await Promise.race([ // [!code highlight]
            hook.then((p) => ({ type: "command" as const, command: p.command })),
            sleep(`${sleepMs}ms`).then(() => ({ type: "timer" as const })),
          ]);

          if (outcome.type === "timer") {
            const nearExpiry = Date.now() >= refreshDeadline;

            if (nearExpiry) {
              // Proactive refresh — snapshot and immediately recreate so the
              // session outlives the sandbox hard cap.
              await emit({ type: "status", state: "refreshing", at: Date.now() });
              const snap = await sandbox.snapshot(); // [!code highlight]
              sandbox = await Sandbox.create({ // [!code highlight]
                source: { type: "snapshot", snapshotId: snap.snapshotId }, // [!code highlight]
                timeout: SANDBOX_TIMEOUT_MS, // [!code highlight]
              });
              sandboxCreatedAt = Date.now();
              sandboxExpiresAt = sandboxCreatedAt + SANDBOX_TIMEOUT_MS;
              await emit({
                type: "status", state: "active", at: Date.now(),
                sandboxId: sandbox.sandboxId, sandboxExpiresAt,
                snapshotId: snap.snapshotId,
              });
              lastActivityAt = Date.now();
            } else {
              // Idle — snapshot and hibernate indefinitely.
              await emit({ type: "status", state: "hibernating", at: Date.now() });
              snapshot = await sandbox.snapshot(); // [!code highlight]
              hibernated = true;
              await emit({
                type: "status", state: "hibernated", at: Date.now(),
                snapshotId: snapshot.snapshotId,
              });
            }
            continue;
          }

          if (outcome.command === "/destroy") { destroyed = true; break; }

          counter += 1;
          await runCommandAndStream(sandbox, `cmd-${counter}`, outcome.command);
          lastActivityAt = Date.now();
          await emit({ type: "activity", at: lastActivityAt });
        }
      } finally {
        if (!hibernated) {
          try {
            if (sandbox.status === "running") await sandbox.stop();
          } catch { /* best-effort */ }
        }
        await emit({ type: "status", state: "destroyed", at: Date.now() });
        await emit({
          type: "result",
          status: "destroyed",
          durationMs: Date.now() - startedAt,
        });
      }
    }
    ```
  </Tab>

  <Tab value="API Routes">
    Two endpoints. `/start` accepts an optional `{ runId }` — if the run still exists, it replays the event log from index 0 so a returning client fully rehydrates. `/command` resumes the hook and returns immediately; command output lands on the `/start` stream.

    <Callout type="info">
      This example starts a fresh sandbox session when no `runId` is provided. If your product needs one sandbox session per user, project, or task, use a deterministic hook token derived from that session key and route retries through the active hook. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
    </Callout>

    ```typescript title="app/api/sandbox/start/route.ts" lineNumbers
    import { start, getRun } from "workflow/api";
    import { sandboxSessionWorkflow } from "@/workflows/sandbox-session";

    export async function POST(req: Request) {
      let body: { runId?: string } = {};
      try {
        const text = await req.text();
        if (text) body = JSON.parse(text);
      } catch { /* ignore malformed body */ }

      // Reconnect path: if the client sends a known runId, stream the durable
      // event log from the beginning so the UI can rehydrate.
      if (body.runId) {
        const run = getRun(body.runId);
        if (await run.exists) { // [!code highlight]
          const readable = run.getReadable({ startIndex: 0 }); // [!code highlight]
          return new Response(readable.pipeThrough(ndjson()), {
            headers: {
              "Content-Type": "application/x-ndjson",
              "x-workflow-run-id": body.runId,
              "x-workflow-reconnected": "true",
              "Cache-Control": "no-cache, no-transform",
            },
          });
        }
        // Stale runId — fall through to start fresh.
      }

      const run = await start(sandboxSessionWorkflow, []);
      return new Response(run.readable.pipeThrough(ndjson()), {
        headers: {
          "Content-Type": "application/x-ndjson",
          "x-workflow-run-id": run.runId,
          "Cache-Control": "no-cache, no-transform",
        },
      });
    }

    function ndjson<T>() {
      return new TransformStream<T, string>({
        transform(chunk, controller) {
          controller.enqueue(JSON.stringify(chunk) + "\n");
        },
      });
    }
    ```

    ```typescript title="app/api/sandbox/command/route.ts" lineNumbers
    import { commandHook } from "@/workflows/sandbox-session";

    export async function POST(req: Request) {
      const { runId, command } = (await req.json()) as { runId?: string; command?: string };

      if (!runId || typeof command !== "string") {
        return Response.json({ error: "runId and command are required" }, { status: 400 });
      }

      try {
        await commandHook.resume(runId, { command }); // [!code highlight]
        return Response.json({ ok: true });
      } catch (error) {
        const msg = error instanceof Error ? error.message.toLowerCase() : "";
        if (msg.includes("not found") || msg.includes("expired")) {
          return Response.json({ ok: false, note: "session expired" }, { status: 410 });
        }
        throw error;
      }
    }
    ```
  </Tab>

  <Tab value="Client">
    On mount, if a `runId` is stashed in `localStorage`, reconnect to the existing run. Otherwise start fresh. Commands are POSTed to `/command` — output lands on the `/start` stream.

    ```tsx title="components/sandbox-runner.tsx" lineNumbers
    "use client";

    import { useCallback, useEffect, useRef, useState } from "react";
    import type { SandboxEvent } from "@/workflows/sandbox-session";

    const RUN_ID_KEY = "sandbox.runId";

    export function SandboxRunner() {
      const [events, setEvents] = useState<SandboxEvent[]>([]);
      const runIdRef = useRef<string | null>(null);
      const didReconnectRef = useRef(false);

      const consume = useCallback(async (res: Response) => {
        if (!res.ok || !res.body) return;
        runIdRef.current = res.headers.get("x-workflow-run-id");
        if (runIdRef.current) {
          localStorage.setItem(RUN_ID_KEY, runIdRef.current); // [!code highlight]
        }

        const reader = res.body.getReader();
        const decoder = new TextDecoder();
        let buffer = "";

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          buffer += decoder.decode(value, { stream: true });
          const lines = buffer.split("\n");
          buffer = lines.pop() ?? "";
          for (const line of lines) {
            if (!line.trim()) continue;
            try {
              setEvents((prev) => [...prev, JSON.parse(line) as SandboxEvent]);
            } catch { /* malformed line */ }
          }
        }
      }, []);

      const openStream = useCallback(
        async (runId?: string) => {
          setEvents([]);
          const res = await fetch("/api/sandbox/start", {
            method: "POST",
            headers: runId ? { "Content-Type": "application/json" } : undefined,
            body: runId ? JSON.stringify({ runId }) : undefined,
          });
          await consume(res);
        },
        [consume]
      );

      // Auto-reconnect on mount if a runId is stashed.
      useEffect(() => {
        if (didReconnectRef.current) return;
        didReconnectRef.current = true;
        const stored = localStorage.getItem(RUN_ID_KEY);
        if (stored) openStream(stored); // [!code highlight]
      }, [openStream]);

      const start = useCallback(() => {
        localStorage.removeItem(RUN_ID_KEY);
        runIdRef.current = null;
        openStream();
      }, [openStream]);

      const sendCommand = useCallback(async (command: string) => {
        if (!runIdRef.current) return;
        const res = await fetch("/api/sandbox/command", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ runId: runIdRef.current, command }),
        });
        if (res.status === 410) localStorage.removeItem(RUN_ID_KEY);
      }, []);

      const destroy = useCallback(async () => {
        await sendCommand("/destroy");
        localStorage.removeItem(RUN_ID_KEY);
      }, [sendCommand]);

      // Render events as a terminal-style log. Drive UI state from `status` events
      // (active / hibernating / hibernated / resuming / refreshing / destroyed).
      return null;
    }
    ```
  </Tab>
</Tabs>

## How It Works

1. **One workflow = one session.** The workflow owns a sandbox for its entire lifetime. The `runId` is the only state the client has to remember.
2. **Hook created once.** `commandHook.create({ token: workflowRunId })` outside the loop. Creating it twice with the same token throws `HookConflictError`.
3. **Two timer branches.** The active-state race wakes on the earlier of `idleDeadline` and `refreshDeadline`. The hibernated state awaits the hook alone — no timer, no compute.
4. **Proactive refresh.** `refreshDeadline = sandboxExpiresAt - REFRESH_SAFETY_MS`. Hitting this triggers a snapshot + immediate new sandbox from that snapshot, rolling over the hard cap without user intervention.
5. **`sandbox.snapshot()` stops the VM.** It's documented as part of the snapshot process — don't call `stop()` separately.
6. **Resume = new sandbox.** `Sandbox.create({ source: { type: "snapshot", snapshotId } })` creates a fresh VM from the snapshot. The new sandbox has a different `sandboxId`; filesystem, installed packages, and git history are preserved.
7. **Reconnect by runId.** `getRun(runId).getReadable({ startIndex: 0 })` replays the durable event log to a returning client, who rebuilds UI state from the replay.
8. **Exit only on `/destroy`.** The workflow loop has no hard deadline of its own. Individual sandboxes time out; the session doesn't.

## Pitfalls

### `sandbox.stop()` is terminal

A stopped sandbox cannot be restarted — you have to create a new one. Hibernation is only possible via `snapshot()` + new-sandbox-from-snapshot. Don't try to "pause" an active sandbox with `stop()` and resume later.

### `snapshot()` already stops the VM

Calling `stop()` after `snapshot()` either errors or is a no-op depending on timing. Snapshot takes care of it.

### New `sandboxId` after resume and refresh

Both `resuming` (idle → command) and `refreshing` (near-hard-cap rotation) create a new sandbox with a new `sandboxId`. Emit it on the subsequent `status: "active"` event and have the UI read from there, not from the initial `created` event.

### Keep the refresh margin generous

`snapshot()` + `Sandbox.create({ source })` takes real time (typically tens of seconds). If `REFRESH_SAFETY_MS` is too small, the old sandbox hits its hard cap mid-snapshot. Leave at least 60–90 seconds; 5 minutes is comfortable.

### Don't call `writable.close()` inside a workflow function

Stream closure must happen inside a `"use step"` function. Calling `writable.close()` directly in the workflow body throws `Not supported in workflow functions`. The runtime closes the underlying writable when the workflow returns.

### Handle stale `runId` gracefully

Clients can hold `runId`s from long-gone workflow runs (localStorage, back button, server restart). Gate the reconnect path on `run.exists` and fall through to starting fresh. On `hook.resume`, catch `not found` / `expired` and return 410 so the client clears its state.

### Decide whether `/start` should be idempotent

The sample treats a missing or stale `runId` as a request for a new session. For one-session-per-resource behavior, use a durable resource key, such as `projectId` or `taskId`, to claim or retrieve the run before starting a new one.

### Keep the hook outside the loop

Each iteration's `hook.then(...)` attaches a listener to the same hook instance. Creating a new hook per iteration with the same token throws `HookConflictError`. One hook, one token (`workflowRunId`), reused every iteration.

## Key APIs

* [`Sandbox.create`](https://vercel.com/docs/sandbox) — provision a VM (runtime, source, timeout)
* [`sandbox.runCommand`](https://vercel.com/docs/sandbox) — execute a command; implicit step
* [`sandbox.snapshot`](https://vercel.com/docs/sandbox) — save state and stop the VM; returns `Snapshot`
* [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for user commands
* [`sleep()`](/docs/api-reference/workflow/sleep) — durable timer that powers both idle hibernation and proactive refresh
* [`getRun()`](/docs/api-reference/workflow-api/get-run) — look up a run and replay its event log for reconnection
* [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable NDJSON event stream
* [Idempotency](/docs/foundations/idempotency) — choose when `/start` should reuse an existing run


---
title: Local World
description: Zero-config world bundled with Workflow for local development. No external services required.
type: integration
summary: Set up the Local World for zero-config workflow development on your machine.
prerequisites:
  - /docs/deploying
related:
  - /docs/deploying/world/postgres-world
  - /docs/deploying/world/vercel-world
---

# Local World



The Local World is bundled with `workflow` and used automatically during local development. No installation or configuration required.

To explicitly use the local world in any environment, set the environment variable:

```bash
WORKFLOW_TARGET_WORLD=local
```

## Observability

The `workflow` CLI uses the local world by default. Running these commands inside your workflow project will show your local development workflows:

```bash
# List recent workflow runs
npx workflow inspect runs

# Launch the web UI
npx workflow web
```

Learn more in the [Observability](/docs/observability) documentation.

## Testing & Compatibility

<WorldTestingPerformance />

## Configuration

The local world works with zero configuration, but you can customize behavior through environment variables or programmatically via `createLocalWorld()`.

### `WORKFLOW_LOCAL_DATA_DIR`

Directory for storing workflow data as JSON files. Default: `.workflow-data/`

### `PORT`

The application dev server port. Used to enqueue steps and workflows. Default: auto-detected

### `WORKFLOW_LOCAL_BASE_URL`

Full base URL override for HTTPS or custom hostnames. Default: `http://localhost:{port}`

Port resolution priority: `baseUrl` > `port` > `PORT` > auto-detected

### `WORKFLOW_LOCAL_QUEUE_CONCURRENCY`

Maximum number of concurrent queue workers. Default: `100`

### Programmatic configuration

{/* @skip-typecheck: incomplete code sample */}

```typescript title="workflow.config.ts" lineNumbers
import { createLocalWorld } from "@workflow/world-local";

const world = createLocalWorld({
  dataDir: "./custom-workflow-data",
  port: 5173,
  // baseUrl overrides port if set
  baseUrl: "https://local.example.com:3000",
});
```

## Limitations

The local world is designed for development, not production:

* **In-memory queue** - Steps are queued in memory and do not persist across server restarts
* **Filesystem storage** - Data is stored in local JSON files
* **Single instance** - Cannot handle distributed deployments
* **No authentication** - Suitable only for local development

For production deployments, use the [Vercel World](/worlds/vercel) or [Postgres World](/worlds/postgres).


---
title: Postgres World
description: Production-ready, self-hosted world using PostgreSQL for storage and graphile-worker for job processing.
type: integration
summary: Deploy workflows to your own infrastructure using PostgreSQL and graphile-worker.
prerequisites:
  - /docs/deploying
related:
  - /docs/deploying/world/local-world
  - /docs/deploying/world/vercel-world
---

# Postgres World



The Postgres World is a production-ready backend for self-hosted deployments. It uses PostgreSQL for durable storage and [graphile-worker](https://github.com/graphile/worker) for reliable job processing.

Use the Postgres World when you need to deploy workflows on your own infrastructure outside of Vercel - such as a Docker container, Kubernetes cluster, or any cloud that supports long-running servers.

## Installation

Install the Postgres World package in your workflow project:

<CodeBlockTabs defaultValue="npm">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="npm">
      npm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="pnpm">
      pnpm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="yarn">
      yarn
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="bun">
      bun
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="npm">
    ```bash
    npm install @workflow/world-postgres
    ```
  </CodeBlockTab>

  <CodeBlockTab value="pnpm">
    ```bash
    pnpm add @workflow/world-postgres
    ```
  </CodeBlockTab>

  <CodeBlockTab value="yarn">
    ```bash
    yarn add @workflow/world-postgres
    ```
  </CodeBlockTab>

  <CodeBlockTab value="bun">
    ```bash
    bun add @workflow/world-postgres
    ```
  </CodeBlockTab>
</CodeBlockTabs>

Configure the required environment variables to use the world and point it to your PostgreSQL database:

```bash title=".env"
WORKFLOW_TARGET_WORLD="@workflow/world-postgres"
WORKFLOW_POSTGRES_URL="postgres://user:password@host:5432/database"
```

Run the migration script to create the necessary tables in your database. Ensure `WORKFLOW_POSTGRES_URL` is set when running this command:

<Tabs items={["npm", "pnpm", "Yarn", "Bun"]}>
  <Tab value="npm">
    ```bash
    npx --package=@workflow/world-postgres bootstrap
    ```
  </Tab>

  <Tab value="pnpm">
    ```bash
    pnpm dlx --package @workflow/world-postgres bootstrap
    ```
  </Tab>

  <Tab value="Yarn">
    ```bash
    yarn dlx --package @workflow/world-postgres bootstrap
    ```
  </Tab>

  <Tab value="Bun">
    ```bash
    bunx --package @workflow/world-postgres bootstrap
    ```
  </Tab>
</Tabs>

<Callout type="info">
  The migration is idempotent and can safely be run as a post-deployment lifecycle script.
</Callout>

## Starting the World

To subscribe to the graphile-worker queue, your workflow app needs to start the world on server start. Here are examples for a few frameworks:

<Tabs items={["Next.js", "SvelteKit", "Nitro"]}>
  <Tab value="Next.js">
    Create an `instrumentation.ts` file in your project root:

    ```ts title="instrumentation.ts" lineNumbers
    export async function register() {
      if (process.env.NEXT_RUNTIME !== "edge") {
        const { getWorld } = await import("workflow/runtime");
        const world = await getWorld();
        await world.start?.();
      }
    }
    ```

    <Callout type="info">
      Learn more about [Next.js Instrumentation](https://nextjs.org/docs/app/guides/instrumentation).
    </Callout>
  </Tab>

  <Tab value="SvelteKit">
    Create a `src/hooks.server.ts` file:

    ```ts title="src/hooks.server.ts" lineNumbers
    import type { ServerInit } from "@sveltejs/kit";

    export const init: ServerInit = async () => {
      const { getWorld } = await import("workflow/runtime");
      const world = await getWorld();
      await world.start?.();
    };
    ```

    <Callout type="info">
      Learn more about [SvelteKit Hooks](https://svelte.dev/docs/kit/hooks).
    </Callout>
  </Tab>

  <Tab value="Nitro">
    Create a plugin to start the world on server initialization:

    ```ts title="plugins/start-pg-world.ts" lineNumbers
    import { defineNitroPlugin } from "nitro/~internal/runtime/plugin";

    export default defineNitroPlugin(async () => {
      const { getWorld } = await import("workflow/runtime");
      const world = await getWorld();
      await world.start?.();
    });
    ```

    Register the plugin in your config:

    ```ts title="nitro.config.ts"
    import { defineNitroConfig } from "nitropack";

    export default defineNitroConfig({
      modules: ["workflow/nitro"],
      plugins: ["plugins/start-pg-world.ts"],
    });
    ```

    <Callout type="info">
      Learn more about [Nitro Plugins](https://v3.nitro.build/docs/plugins).
    </Callout>
  </Tab>
</Tabs>

<Callout type="info">
  The Postgres World requires a long-lived worker process that polls the database for jobs. This does not work on serverless environments. For Vercel deployments, use the [Vercel World](/worlds/vercel) instead.
</Callout>

## Observability

Use the `workflow` CLI to inspect workflows stored in PostgreSQL:

```bash
# Set your database URL
export WORKFLOW_POSTGRES_URL="postgres://user:password@host:5432/database"

# List workflow runs
npx workflow inspect runs --backend @workflow/world-postgres

# Launch the web UI
npx workflow web --backend @workflow/world-postgres
```

If `WORKFLOW_POSTGRES_URL` is not set, the CLI defaults to `postgres://world:world@localhost:5432/world`.

Learn more in the [Observability](/docs/observability) documentation.

## Testing & Compatibility

<WorldTestingPerformance />

## Configuration

All configuration options can be set via environment variables or programmatically via `createWorld()`.

### `WORKFLOW_POSTGRES_URL` (required)

PostgreSQL connection string. Falls back to `DATABASE_URL` if not set.

Default: `postgres://world:world@localhost:5432/world`

### `WORKFLOW_POSTGRES_JOB_PREFIX`

Prefix for graphile-worker queue job names. Useful when sharing a database between multiple applications.

### `WORKFLOW_POSTGRES_WORKER_CONCURRENCY`

Number of concurrent workers polling for jobs. Default: `50`.

This value also bounds how many parent→child workflow polls can be in flight simultaneously. Every `await childRun.returnValue` inside a workflow holds a worker slot until the child run terminates — if you expect recursive or highly-fanned-out parent/child workflows, raise this ceiling above the peak number of concurrent polls. With the default of 50, the included `fibonacciWorkflow` e2e test (fib(6), \~24 concurrent polls at peak) passes; deeper recursion or larger fanouts need a correspondingly larger setting.

### `WORKFLOW_POSTGRES_MAX_POOL_SIZE`

Maximum size of the internal `pg.Pool` used when `createWorld()` constructs the pool. Default: `10`

For higher worker concurrency, Graphile Worker recommends setting `maxPoolSize` to `10` or `queueConcurrency + 2`, whichever is larger.

### Programmatic configuration

{/*@skip-typecheck: incomplete code sample*/}

```typescript title="workflow.config.ts" lineNumbers
import { createWorld } from "@workflow/world-postgres";

const world = createWorld({
  connectionString: "postgres://user:password@host:5432/database",
  jobPrefix: "myapp_",
  queueConcurrency: 50,
  maxPoolSize: 52, // overrides WORKFLOW_POSTGRES_MAX_POOL_SIZE
});
```

## How It Works

The Postgres World uses PostgreSQL as a durable backend:

* **Storage** - Workflow runs, events, steps, and hooks are stored in PostgreSQL tables
* **Job Queue** - [graphile-worker](https://github.com/graphile/worker) handles reliable job processing with retries
* **Streaming** - PostgreSQL NOTIFY/LISTEN enables real-time event distribution

This architecture ensures workflows survive application restarts with all state reliably persisted. For implementation details, see the [source code](https://github.com/vercel/workflow/tree/main/packages/world-postgres).

## Deployment

Deploy your application to any cloud that supports long-running servers:

* Docker containers
* Kubernetes clusters
* Virtual machines
* Platform-as-a-Service providers (Railway, Render, Fly.io, etc.)

Ensure your deployment has:

1. Network access to your PostgreSQL database
2. Environment variables configured correctly
3. The `start()` function called on server initialization

<Callout type="info">
  The Postgres World is not compatible with Vercel deployments. On Vercel, workflows automatically use the [Vercel World](/worlds/vercel) with zero configuration.
</Callout>

## Limitations

* **Requires long-running process** - Must call `start()` on server initialization; not compatible with serverless platforms
* **PostgreSQL infrastructure** - Requires a PostgreSQL database (self-hosted or managed)
* **Not compatible with Vercel** - Use the [Vercel World](/worlds/vercel) for Vercel deployments

For local development, use the [Local World](/worlds/local) which requires no external services.


---
title: Vercel World
description: Fully-managed world for Vercel deployments with automatic storage, queuing, and authentication.
type: integration
summary: Deploy workflows to Vercel with fully-managed storage, queuing, and authentication.
prerequisites:
  - /docs/deploying
related:
  - /docs/how-it-works/encryption
  - /docs/deploying/world/local-world
  - /docs/deploying/world/postgres-world
---

# Vercel World



The Vercel World is a fully-managed workflow backend for applications deployed on Vercel. It provides scalable storage, distributed queuing, and automatic authentication with zero configuration.

When you deploy to Vercel, workflows automatically use the Vercel World - no setup required.

## Usage

Deploy your application to Vercel:

```bash
vercel deploy
```

That's it. Vercel automatically:

* Selects the Vercel World backend
* Configures authentication using OIDC tokens
* Provisions storage and queuing infrastructure
* Isolates data per environment (production, preview, development)

<FluidComputeCallout />

## Vercel platform documentation

For complete details on pricing, usage limits, and included allotments on Vercel, see the official Vercel documentation:

* **[Vercel Workflow](https://vercel.com/docs/workflow)** — Pricing details, concepts, and observability for Workflow on Vercel
* **[Vercel limits](https://vercel.com/docs/limits)** — Platform-wide limits including Workflow-specific constraints
* **[Vercel Hobby plan](https://vercel.com/docs/plans/hobby)** — Free tier included usage for Workflow and other resources

For self-hosted deployments, use the [Postgres World](/worlds/postgres). For local development, use the [Local World](/worlds/local).

## Limitations

* **Single-region deployment** - The backend infrastructure is currently deployed only in `iad1`. Applications in other regions will route workflow requests to `iad1`, which may result in higher latency. For best performance, deploy your Vercel apps using Workflow to `iad1`. Global deployment is planned to colocate the backend closer to your applications.

* **Data residency** - The Vercel World is currently deployed in the `iad1` region. This means independently of the deployment location of your application, the data for your workflows will be stored in the `iad1` region.

## Observability

Workflow observability is built into the Vercel dashboard on your project page. It respects your existing authentication and project permission settings.

The `workflow` CLI commands open a browser window deeplinked to the Vercel dashboard:

```bash
# List workflow runs (opens Vercel dashboard)
npx workflow inspect runs --backend vercel

# Launch the web UI (opens Vercel dashboard)
npx workflow web --backend vercel
```

The CLI automatically retrieves authentication from the Vercel CLI (`vercel login`) and infers project/team IDs from your local Vercel project linking.

To use the local observability UI instead of the Vercel dashboard:

```bash
npx workflow web --backend vercel --localUi
```

To override the automatic configuration:

```bash
npx workflow inspect runs \
  --backend vercel \
  --env production \
  --project my-project \
  --team my-team \
  --authToken <your-token>
```

Learn more in the [Observability](/docs/observability) documentation.

## Testing & Compatibility

<WorldTestingPerformance />

## Configuration

The Vercel World requires no configuration when deployed to Vercel. For advanced use cases, you can override settings programmatically via `createVercelWorld()`.

### `WORKFLOW_VERCEL_ENV`

The Vercel environment to use. Options: `production`, `preview`, `development`. Automatically detected.

### `WORKFLOW_VERCEL_AUTH_TOKEN`

Authentication token for API requests. Automatically detected.

### `WORKFLOW_VERCEL_PROJECT`

Vercel project ID for API requests. Automatically detected.

### `WORKFLOW_VERCEL_TEAM`

Vercel team ID for API requests. Automatically detected.

### `WORKFLOW_VERCEL_BACKEND_URL`

Custom base URL for the Vercel workflow API. Automatically detected.

### Programmatic configuration

{/*@skip-typecheck: incomplete code sample*/}

```typescript title="workflow.config.ts" lineNumbers
import { createVercelWorld } from "@workflow/world-vercel";

const world = createVercelWorld({
  token: process.env.WORKFLOW_VERCEL_AUTH_TOKEN,
  baseUrl: "https://api.vercel.com/v1/workflow",
  projectConfig: {
    projectId: "my-project",
    teamId: "my-team",
    environment: "production",
  },
});
```

## Versioning

On Vercel, workflow runs are pegged to the deployment that started them. This means:

* Existing workflow runs continue executing on their original deployment, even as new code is deployed
* New workflow runs start on the latest deployment
* Code changes won't break in-flight workflows

This ensures long-running workflows complete reliably without being affected by subsequent deployments.

For the full model, including rerunning on latest and explicit upgrade boundaries, see [Versioning](/docs/foundations/versioning).

## Security

### Consumer function security

Workflow handler functions on Vercel are not accessible through public endpoints. During the build step, the Workflow SDK registers each handler as only reachable by [Vercel Queue](https://vercel.com/docs/queues), by using the `experimentalTriggers` configuration in `.vc-config.json`:

```json title=".vc-config.json (step handler)"
{
  "experimentalTriggers": [
    {
      "type": "queue/v2beta",
      "topic": "__wkf_step_*",
      "consumer": "default",
    }
  ]
}
```

Practically, this means:

* You don't need to add authentication or authorization logic to workflow handlers
* Unauthorized requests can never reach the step or workflow functions
* Only messages delivered through Vercel Queues can trigger execution
* Handlers receive only a message ID that must be retrieved from Vercel's backend, making it impossible to craft custom payloads

<Callout>
  This configuration is managed entirely by the Workflow SDK build step. You should not need to write this yourself. If you are writing a custom integration, see [Framework Integrations — Security](/docs/how-it-works/framework-integrations#security) for more details.
</Callout>

## How It Works

The Vercel World uses Vercel's infrastructure for workflow execution:

* **Storage** - Workflow data is stored in Vercel's cloud with automatic replication and [end-to-end encryption](/docs/how-it-works/encryption)
* **Queuing** - Steps are distributed across serverless functions via [Vercel Queues](https://vercel.com/docs/queues) with automatic retries and [consumer function security](#consumer-function-security)
* **Authentication** - OIDC tokens provide secure, automatic authentication

For more details, see the [Vercel Workflow documentation](https://vercel.com/docs/workflow).


---
title: World SDK
description: Low-level API for inspecting and managing workflow runs, steps, events, hooks, streams, and queues.
type: overview
summary: Access workflow infrastructure via await getWorld() for building observability dashboards, admin tools, and custom integrations.
prerequisites:
  - /docs/api-reference/workflow-runtime/get-world
---

# World SDK



The World SDK provides direct access to workflow infrastructure — runs, steps, events, hooks, streams, and queues. Use it to build observability dashboards, admin panels, debugging tools, and custom workflow management logic.

```typescript lineNumbers
import { getWorld } from "workflow/runtime";

const world = await getWorld(); // [!code highlight]
```

## Interfaces

<Cards>
  <Card href="/docs/api-reference/workflow-runtime/world/storage" title="Storage">
    Query runs, steps, hooks, and the underlying event log.
  </Card>

  <Card href="/docs/api-reference/workflow-runtime/world/streams" title="Streams">
    Read, write, and manage real-time data streams for workflow runs.
  </Card>

  <Card href="/docs/api-reference/workflow-runtime/world/queue" title="Queue">
    Low-level queue dispatch (internal SDK infrastructure).
  </Card>
</Cards>

<Callout type="info">
  The World SDK is the low-level foundation that higher-level functions like [`getRun()`](/docs/api-reference/workflow-api/get-run) and [`start()`](/docs/api-reference/workflow-api/start) are built on. Use it when you need capabilities beyond what those functions provide.
</Callout>

## Data Hydration

Step input/output data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. To display this data in your UI, use the hydration utilities from `workflow/observability`:

```typescript lineNumbers
import { hydrateResourceIO, observabilityRevivers } from "workflow/observability"; // [!code highlight]

const step = await world.steps.get(runId, stepId);
const hydrated = hydrateResourceIO(step, observabilityRevivers); // [!code highlight]
console.log(hydrated.input, hydrated.output);
```

See [`workflow/observability`](/docs/api-reference/workflow-observability) for the full API.


---
title: Queue
description: Low-level queue interface for dispatching workflow and step invocations.
type: reference
summary: Methods: getDeploymentId(), queue(), createQueueHandler(). Internal queue dispatch — normally handled by the SDK.
prerequisites:
  - /docs/api-reference/workflow-runtime/get-world
related:
  - /docs/api-reference/workflow-api/start
  - /docs/foundations/starting-workflows
---

# Queue



Queue methods live directly on the `world` object (not nested). They dispatch internal workflow and step invocations to the queue backend.

<Callout type="warn">
  These methods are used internally by the Workflow SDK to dispatch execution. You do not need to call them in normal operations — use [`start()`](/docs/api-reference/workflow-api/start) to trigger workflows instead. Direct queue access is only needed if you programmatically create a run via `world.events.create()` with a `run_created` event and need to kick off its initial execution, or for debugging resumption of a flow or step route.
</Callout>

## Import

```typescript lineNumbers
import { getWorld } from "workflow/runtime";

const world = await getWorld(); // [!code highlight]
// Queue methods are called directly on world — e.g. world.queue()
```

## Methods

### getDeploymentId()

Get the current deployment ID. Used internally for routing queue messages to the correct deployment.

```typescript lineNumbers
const deploymentId = await world.getDeploymentId(); // [!code highlight]
```

**Returns:** `string` — The current deployment ID

### queue()

Dispatch a message to a named queue. The message payload is an internal SDK type (`WorkflowInvokePayload`, `StepInvokePayload`, or `HealthCheckPayload`).

```typescript lineNumbers
const { messageId } = await world.queue(queueName, payload, opts); // [!code highlight]
```

**Parameters:**

| Parameter   | Type             | Description                                                            |
| ----------- | ---------------- | ---------------------------------------------------------------------- |
| `queueName` | `ValidQueueName` | The queue name (branded string)                                        |
| `message`   | `QueuePayload`   | Internal SDK payload                                                   |
| `opts`      | `QueueOptions`   | Optional — `deploymentId`, `idempotencyKey`, `delaySeconds`, `headers` |

**Returns:** `{ messageId: MessageId | null }`

### createQueueHandler()

Create an HTTP handler that processes messages from a queue. Used to set up the queue consumer endpoint.

```typescript lineNumbers
const handler = world.createQueueHandler(prefix, callback); // [!code highlight]
```

**Parameters:**

| Parameter  | Type                                                             | Description                                                                                        |
| ---------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `prefix`   | `QueuePrefix`                                                    | Queue name prefix to match                                                                         |
| `callback` | `(message, meta) => Promise<void \| { timeoutSeconds: number }>` | Handler called for each message. `meta` contains `attempt`, `queueName`, `messageId`, `requestId`. |

**Returns:** `(req: Request) => Promise<Response>`

## Related

* [start()](/docs/api-reference/workflow-api/start) — The standard way to start workflow runs
* [Starting Workflows](/docs/foundations/starting-workflows) — Core concepts for workflow invocation
* [Storage](/docs/api-reference/workflow-runtime/world/storage) — Create events that trigger queue dispatch


---
title: Storage
description: Query workflow runs, steps, hooks, and the underlying event log via the World storage interface.
type: reference
summary: Interfaces: world.events, world.runs, world.steps, world.hooks. Events are the source of truth; runs, steps, and hooks are materialized views.
prerequisites:
  - /docs/api-reference/workflow-runtime/get-world
related:
  - /docs/api-reference/workflow-api/get-run
  - /docs/how-it-works/event-sourcing
  - /docs/api-reference/workflow-observability
---

# Storage



The World storage interface exposes four sub-interfaces for querying workflow data:

* **`world.events`** — The append-only event log. This is the source of truth for all workflow state. See [Event Sourcing](/docs/how-it-works/event-sourcing) for background.
* **`world.runs`**, **`world.steps`**, **`world.hooks`** — Materialized views derived from the event log, provided as convenience accessors for the most common query patterns.

```typescript lineNumbers
import { getWorld } from "workflow/runtime";

const world = await getWorld(); // [!code highlight]
```

***

## world.events

The event log drives all workflow state. Use it for audit trails, debugging, and programmatic run cancellation.

### events.create()

Create a new event for a workflow run. Most commonly used to cancel a run.

```typescript lineNumbers
await world.events.create(runId, { // [!code highlight]
  eventType: "run_cancelled", // [!code highlight]
}); // [!code highlight]
```

| Parameter | Type                 | Description                                                                                  |
| --------- | -------------------- | -------------------------------------------------------------------------------------------- |
| `runId`   | `string \| null`     | The workflow run ID (`null` only for `run_created` events, where the server generates an ID) |
| `data`    | `CreateEventRequest` | Event data including `eventType`                                                             |
| `params`  | `object`             | Optional parameters                                                                          |

**Returns:** `EventResult` — The created event and the affected entity (run/step/hook)

### events.get()

Retrieve a single event by run ID and event ID.

```typescript lineNumbers
const event = await world.events.get(runId, eventId); // [!code highlight]
```

| Parameter | Type     | Description         |
| --------- | -------- | ------------------- |
| `runId`   | `string` | The workflow run ID |
| `eventId` | `string` | The event ID        |

**Returns:** `Event`

### events.list()

List events for a run with cursor pagination.

```typescript lineNumbers
const result = await world.events.list({ runId, pagination: { cursor } }); // [!code highlight]
```

| Parameter                  | Type     | Description              |
| -------------------------- | -------- | ------------------------ |
| `params.runId`             | `string` | Filter events by run ID  |
| `params.pagination.cursor` | `string` | Cursor for the next page |

**Returns:** `{ data: Event[], cursor?: string }`

### events.listByCorrelationId()

List events that share a correlation ID, useful for tracing related events across runs.

```typescript lineNumbers
const result = await world.events.listByCorrelationId({ // [!code highlight]
  correlationId: "order-123",
}); // [!code highlight]
```

| Parameter                  | Type     | Description                     |
| -------------------------- | -------- | ------------------------------- |
| `params.correlationId`     | `string` | The correlation ID to filter by |
| `params.pagination.cursor` | `string` | Cursor for the next page        |

**Returns:** `{ data: Event[], cursor?: string }`

### Event Types

| Category | Types                                                                            |
| -------- | -------------------------------------------------------------------------------- |
| Run      | `run_created`, `run_started`, `run_completed`, `run_failed`, `run_cancelled`     |
| Step     | `step_created`, `step_started`, `step_completed`, `step_failed`, `step_retrying` |
| Hook     | `hook_created`, `hook_received`, `hook_disposed`, `hook_conflict`                |
| Wait     | `wait_created`, `wait_completed`                                                 |

***

## world.runs

Materialized from run events. Use it to list and inspect workflow runs.

### runs.get()

```typescript lineNumbers
const run = await world.runs.get(runId); // [!code highlight]
```

| Parameter            | Type              | Description                                            |
| -------------------- | ----------------- | ------------------------------------------------------ |
| `runId`              | `string`          | The workflow run ID                                    |
| `params.resolveData` | `'all' \| 'none'` | Whether to include input/output data. Default: `'all'` |

**Returns:** `WorkflowRun` (or `WorkflowRunWithoutData` when `resolveData: 'none'`)

### runs.list()

```typescript lineNumbers
const result = await world.runs.list({ // [!code highlight]
  pagination: { cursor },
}); // [!code highlight]
```

| Parameter                  | Type              | Description                          |
| -------------------------- | ----------------- | ------------------------------------ |
| `params.pagination.cursor` | `string`          | Cursor for the next page             |
| `params.resolveData`       | `'all' \| 'none'` | Whether to include input/output data |

**Returns:** `{ data: WorkflowRun[], cursor?: string }`

### Cancelling Runs

To cancel a run, create a `run_cancelled` event via `world.events.create()` (see [world.events](#worldevents) above), or use the CLI or Web UI helpers.

### WorkflowRun Type

| Field          | Type             | Description                                           |
| -------------- | ---------------- | ----------------------------------------------------- |
| `runId`        | `string`         | Unique run identifier                                 |
| `status`       | `string`         | `'running'`, `'completed'`, `'failed'`, `'cancelled'` |
| `workflowName` | `string`         | Machine-readable workflow identifier                  |
| `input`        | `any`            | Workflow input data (when `resolveData: 'all'`)       |
| `output`       | `any`            | Workflow output data (when `resolveData: 'all'`)      |
| `error`        | `any`            | Error data if the run failed                          |
| `startedAt`    | `string`         | ISO timestamp when the run started                    |
| `completedAt`  | `string \| null` | ISO timestamp when the run completed                  |

<Callout type="warn">
  `workflowName` is a machine-readable identifier like `workflow//./src/workflows/order//processOrder`. Use `parseWorkflowName()` from `workflow/observability` to extract a display-friendly name.
</Callout>

***

## world.steps

Materialized from step events. Use it to list steps, inspect their input/output, and build progress dashboards.

### steps.get()

```typescript lineNumbers
const step = await world.steps.get(runId, stepId); // [!code highlight]
```

| Parameter            | Type                  | Description                                            |
| -------------------- | --------------------- | ------------------------------------------------------ |
| `runId`              | `string \| undefined` | The workflow run ID                                    |
| `stepId`             | `string`              | The step ID                                            |
| `params.resolveData` | `'all' \| 'none'`     | Whether to include input/output data. Default: `'all'` |

**Returns:** `Step` (or `StepWithoutData` when `resolveData: 'none'`)

### steps.list()

```typescript lineNumbers
const result = await world.steps.list({ // [!code highlight]
  runId,
  pagination: { cursor },
}); // [!code highlight]
```

| Parameter                  | Type              | Description                          |
| -------------------------- | ----------------- | ------------------------------------ |
| `params.runId`             | `string`          | Filter steps by run ID               |
| `params.pagination.cursor` | `string`          | Cursor for the next page             |
| `params.resolveData`       | `'all' \| 'none'` | Whether to include input/output data |

**Returns:** `{ data: Step[], cursor?: string }`

### Step Type

| Field         | Type             | Description                                  |
| ------------- | ---------------- | -------------------------------------------- |
| `runId`       | `string`         | Parent workflow run ID                       |
| `stepId`      | `string`         | Unique step identifier                       |
| `stepName`    | `string`         | Machine-readable step identifier             |
| `status`      | `string`         | `'running'`, `'completed'`, `'failed'`       |
| `input`       | `any`            | Step input data (when `resolveData: 'all'`)  |
| `output`      | `any`            | Step output data (when `resolveData: 'all'`) |
| `error`       | `any`            | Error data if the step failed                |
| `attempt`     | `number`         | Current retry attempt number                 |
| `startedAt`   | `string`         | ISO timestamp when the step started          |
| `completedAt` | `string \| null` | ISO timestamp when the step completed        |
| `retryAfter`  | `string \| null` | ISO timestamp for next retry attempt         |

<Callout type="info">
  Step I/O is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. Use `hydrateResourceIO()` from `workflow/observability` to deserialize it for display. See [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io).
</Callout>

<Callout type="warn">
  `stepName` is a machine-readable identifier like `step//./src/workflows/order//processPayment`. Use `parseStepName()` from `workflow/observability` to extract the `shortName` for UI display.
</Callout>

***

## world.hooks

Materialized from hook events. Hooks are pause points in workflows that wait for external input. Use this interface to look up hooks by ID or token, inspect metadata, and build UIs for pending approvals.

### hooks.get()

```typescript lineNumbers
const hook = await world.hooks.get(hookId); // [!code highlight]
```

| Parameter | Type     | Description |
| --------- | -------- | ----------- |
| `hookId`  | `string` | The hook ID |

**Returns:** `Hook`

### hooks.getByToken()

Look up a hook by its token. Useful in webhook resume flows where you receive a token in the callback URL.

<Callout type="info">
  For runtime application code, prefer [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token). Use `world.hooks.getByToken()` when you are working directly with the World storage interface for custom tooling, admin views, or low-level integrations.

  Hook-token lookup is the low-level form of the recommended idempotency flow: if a hook is already registered for your business key, reuse the hook's `runId` or resume that hook instead of starting another run. If no hook exists yet, start a workflow that creates the deterministic hook near the beginning and checks `await hook.getConflict()` to detect whether another run claimed the token first — on a conflict it resolves with the run that owns the token. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
</Callout>

```typescript lineNumbers
const hook = await world.hooks.getByToken(token); // [!code highlight]
```

| Parameter | Type     | Description    |
| --------- | -------- | -------------- |
| `token`   | `string` | The hook token |

**Returns:** `Hook`

### hooks.list()

```typescript lineNumbers
const result = await world.hooks.list({ // [!code highlight]
  pagination: { cursor },
}); // [!code highlight]
```

| Parameter                  | Type     | Description              |
| -------------------------- | -------- | ------------------------ |
| `params.pagination.cursor` | `string` | Cursor for the next page |

**Returns:** `{ data: Hook[], cursor?: string }`

### Hook Type

| Field         | Type      | Description                          |
| ------------- | --------- | ------------------------------------ |
| `runId`       | `string`  | Parent workflow run ID               |
| `hookId`      | `string`  | Unique hook identifier               |
| `token`       | `string`  | Hook token for resuming              |
| `ownerId`     | `string`  | Owner (team/user) ID                 |
| `projectId`   | `string`  | Project ID                           |
| `environment` | `string`  | Deployment environment               |
| `metadata`    | `object`  | Custom metadata attached to the hook |
| `isWebhook`   | `boolean` | Whether this is a webhook-style hook |

***

## Examples

### List Runs with Pagination

```typescript lineNumbers
import { getWorld } from "workflow/runtime";

const world = await getWorld();
let cursor: string | undefined;

const runs = await world.runs.list({ // [!code highlight]
  pagination: { cursor },
}); // [!code highlight]

cursor = runs.cursor; // pass to next call for pagination
```

### Get a Run — Full Data vs. Metadata Only

```typescript lineNumbers
import { getWorld } from "workflow/runtime";

const world = await getWorld();

// Full data (default) — includes serialized input/output
const run = await world.runs.get(runId); // [!code highlight]

// Metadata only — lighter, no I/O loaded
const lightweight = await world.runs.get(runId, { // [!code highlight]
  resolveData: "none", // [!code highlight]
}); // [!code highlight]
```

### List Steps for a Progress Dashboard

```typescript lineNumbers
import { getWorld } from "workflow/runtime";
import { parseStepName } from "workflow/observability"; // [!code highlight]

const world = await getWorld();
const steps = await world.steps.list({ // [!code highlight]
  runId,
  resolveData: "none",
}); // [!code highlight]

const progress = steps.data.map((step) => {
  const parsed = parseStepName(step.stepName); // [!code highlight]
  return {
    stepId: step.stepId,
    displayName: parsed?.shortName ?? step.stepName, // [!code highlight]
    status: step.status,
  };
});
```

### Hydrate Step I/O

```typescript lineNumbers
import { getWorld } from "workflow/runtime";
import { hydrateResourceIO, observabilityRevivers } from "workflow/observability"; // [!code highlight]

const world = await getWorld();
const step = await world.steps.get(runId, stepId); // [!code highlight]
const hydrated = hydrateResourceIO(step, observabilityRevivers); // [!code highlight]
console.log(hydrated.input, hydrated.output);
```

### Cancel a Run

```typescript lineNumbers
import { getWorld } from "workflow/runtime";

const world = await getWorld();
await world.events.create(runId, { // [!code highlight]
  eventType: "run_cancelled", // [!code highlight]
}); // [!code highlight]
```

### Look Up Hook by Token

```typescript lineNumbers
import { getWorld } from "workflow/runtime";

const world = await getWorld();
const hook = await world.hooks.getByToken(token); // [!code highlight]
console.log(hook.runId, hook.metadata); // [!code highlight]
```

### List Events for Audit Trail

```typescript lineNumbers
import { getWorld } from "workflow/runtime";

const world = await getWorld();
const events = await world.events.list({ runId }); // [!code highlight]

for (const event of events.data) {
  console.log(event.eventType, event.createdAt);
}
```

## Related

* [Event Sourcing](/docs/how-it-works/event-sourcing) — How the event log powers workflow replay and state
* [getRun()](/docs/api-reference/workflow-api/get-run) — Higher-level API for working with individual runs
* [`workflow/observability`](/docs/api-reference/workflow-observability) — Hydrate step I/O and parse display names
* [resumeHook()](/docs/api-reference/workflow-api/resume-hook) — Resume a workflow by sending a payload to a hook
* [Hooks](/docs/foundations/hooks) — Core concepts for hooks and pause points
* [Workflows and Steps](/docs/foundations/workflows-and-steps) — Core concepts for steps


---
title: Streams
description: Read, write, and manage real-time data streams for workflow runs.
type: reference
summary: Methods: streams.write(), streams.writeMulti(), streams.get(), streams.close(), streams.list(), streams.getChunks(), streams.getInfo(). Stream methods live on world.streams.
prerequisites:
  - /docs/api-reference/workflow-runtime/get-world
related:
  - /docs/foundations/streaming
  - /docs/api-reference/workflow/get-writable
---

# Streams



Stream methods live on `world.streams` (the `streams` sub-object of the `World` instance returned by `await getWorld()`). Use them to write chunks, read streams, and manage stream lifecycle outside of the standard `getWritable()` pattern.

<Callout type="info">
  For most streaming use cases, use [`getWritable()`](/docs/api-reference/workflow/get-writable) inside steps. Direct stream methods are for advanced scenarios like building custom stream consumers or managing streams from outside a workflow.
</Callout>

## Import

```typescript lineNumbers
import { getWorld } from "workflow/runtime";

const world = await getWorld(); // [!code highlight]
// Stream methods are called on world.streams — e.g. world.streams.write()
```

## Methods

### write()

Write a data chunk to a named stream.

```typescript lineNumbers
await world.streams.write(runId, "default", chunk); // [!code highlight]
```

**Parameters:**

| Parameter | Type                   | Description         |
| --------- | ---------------------- | ------------------- |
| `runId`   | `string`               | The workflow run ID |
| `name`    | `string`               | The stream name     |
| `chunk`   | `string \| Uint8Array` | Data to write       |

### writeMulti()

Write multiple chunks in a single operation. Optional optimization — not all World implementations support it. Falls back to sequential `write()` calls if unavailable.

```typescript lineNumbers
await world.streams.writeMulti?.(runId, "default", [chunk1, chunk2]); // [!code highlight]
```

**Parameters:**

| Parameter | Type                       | Description               |
| --------- | -------------------------- | ------------------------- |
| `runId`   | `string`                   | The workflow run ID       |
| `name`    | `string`                   | The stream name           |
| `chunks`  | `(string \| Uint8Array)[]` | Chunks to write, in order |

### get()

Read data from a named stream as a live `ReadableStream` that waits for new chunks in real time.

```typescript lineNumbers
const readable = await world.streams.get(runId, "default"); // [!code highlight]
```

**Parameters:**

| Parameter    | Type     | Description                                                                                                                                                |
| ------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `runId`      | `string` | The workflow run ID                                                                                                                                        |
| `name`       | `string` | The stream name                                                                                                                                            |
| `startIndex` | `number` | Optional. Positive values skip chunks from the start (0-based). Negative values read from the tail (e.g. `-3` starts 3 chunks from the end). Clamped to 0. |

**Returns:** `ReadableStream<Uint8Array>`

### close()

Close a stream when done writing.

```typescript lineNumbers
await world.streams.close(runId, "default"); // [!code highlight]
```

**Parameters:**

| Parameter | Type     | Description         |
| --------- | -------- | ------------------- |
| `runId`   | `string` | The workflow run ID |
| `name`    | `string` | The stream name     |

### list()

List all stream names associated with a workflow run.

```typescript lineNumbers
const streamNames = await world.streams.list(runId); // [!code highlight]
```

**Parameters:**

| Parameter | Type     | Description         |
| --------- | -------- | ------------------- |
| `runId`   | `string` | The workflow run ID |

**Returns:** `string[]`

### getChunks()

Fetch stream chunks with cursor-based pagination. Unlike `get()` (which returns a live `ReadableStream`), this returns a snapshot of currently available chunks.

```typescript lineNumbers
const result = await world.streams.getChunks(runId, "default", { // [!code highlight]
  limit: 50,
}); // [!code highlight]
// result.data: StreamChunk[], result.cursor, result.hasMore, result.done
```

**Parameters:**

| Parameter        | Type     | Description                                   |
| ---------------- | -------- | --------------------------------------------- |
| `runId`          | `string` | The workflow run ID                           |
| `name`           | `string` | The stream name                               |
| `options.limit`  | `number` | Max chunks per page (default: 100, max: 1000) |
| `options.cursor` | `string` | Cursor from a previous response               |

**Returns:** `StreamChunksResponse`

| Field     | Type             | Description                                                                                                                 |
| --------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `data`    | `StreamChunk[]`  | Chunks in index order. Each has `index` (0-based) and `data` (`Uint8Array`).                                                |
| `cursor`  | `string \| null` | Cursor for the next page                                                                                                    |
| `hasMore` | `boolean`        | Whether more pages of already-written chunks exist                                                                          |
| `done`    | `boolean`        | Whether the stream is fully closed. When `false`, new chunks may appear in future requests even after `hasMore` is `false`. |

### getInfo()

Retrieve lightweight metadata about a stream without fetching chunks.

```typescript lineNumbers
const info = await world.streams.getInfo(runId, "default"); // [!code highlight]
// info.tailIndex: last chunk index (-1 if empty), info.done: whether stream is closed
```

**Parameters:**

| Parameter | Type     | Description         |
| --------- | -------- | ------------------- |
| `runId`   | `string` | The workflow run ID |
| `name`    | `string` | The stream name     |

**Returns:** `StreamInfoResponse`

| Field       | Type      | Description                                                                     |
| ----------- | --------- | ------------------------------------------------------------------------------- |
| `tailIndex` | `number`  | Index of the last known chunk (0-based). `-1` when no chunks have been written. |
| `done`      | `boolean` | Whether the stream is fully complete (closed).                                  |

## Examples

### Read a Stream as a Response

```typescript lineNumbers
// app/api/workflow-streams/read/route.ts
import { getWorld } from "workflow/runtime";

export async function GET(req: Request) {
  const url = new URL(req.url);
  const streamName = url.searchParams.get("name") ?? "default";
  const runId = url.searchParams.get("runId")!;
  const world = await getWorld();
  const readable = await world.streams.get(runId, streamName); // [!code highlight]

  return new Response(readable, {
    headers: { "Content-Type": "application/octet-stream" },
  });
}
```

### Paginate Through Stream Chunks

```typescript lineNumbers
import { getWorld } from "workflow/runtime";

const world = await getWorld();
let cursor: string | undefined;

do {
  const result = await world.streams.getChunks(runId, "default", { cursor }); // [!code highlight]
  for (const chunk of result.data) {
    console.log(`Chunk ${chunk.index}:`, chunk.data);
  }
  cursor = result.cursor ?? undefined;
} while (cursor);
```

## Related

* [Streaming](/docs/foundations/streaming) — Core concepts for streaming data from workflows
* [getWritable()](/docs/api-reference/workflow/get-writable) — The standard way to write to streams from within steps
* [Storage](/docs/api-reference/workflow-runtime/world/storage) — Query runs, steps, hooks, and events
