workflow

star 0

Vercel Workflow SDK for building durable, resumable workflows in Next.js. Use when creating workflow files with 'use workflow' / 'use step' directives, starting workflows with start(), checking run status with getRun(), configuring withWorkflow() in next.config.ts, implementing durable steps, sleep(), hooks, webhooks, error handling with FatalError/RetryableError, streaming with getWritable(), or idempotency with getStepMetadata(). Triggers on: workflow, durable, 'use step', 'use workflow', withWorkflow, sleep, resumable, step function, workflow/api, workflow/next.

Pibomeister By Pibomeister schedule Updated 2/25/2026

name: workflow description: > Vercel Workflow SDK for building durable, resumable workflows in Next.js. Use when creating workflow files with 'use workflow' / 'use step' directives, starting workflows with start(), checking run status with getRun(), configuring withWorkflow() in next.config.ts, implementing durable steps, sleep(), hooks, webhooks, error handling with FatalError/RetryableError, streaming with getWritable(), or idempotency with getStepMetadata(). Triggers on: workflow, durable, 'use step', 'use workflow', withWorkflow, sleep, resumable, step function, workflow/api, workflow/next.

Vercel Workflow SDK

Durable, resumable workflows for Next.js. Workflows survive server restarts, deploy between steps, and consume zero compute while waiting.

Quick Reference

Setup

Install and configure:

pnpm add workflow

Wrap next.config.ts with withWorkflow():

import { withWorkflow } from 'workflow/next'

const nextConfig = {}
export default withWorkflow(nextConfig)

When composing with other plugins, call each in sequence:

import { withWorkflow } from 'workflow/next'
import createNextIntlPlugin from 'next-intl/plugin'

const withNextIntl = createNextIntlPlugin()
export default withWorkflow(withNextIntl(nextConfig))

Imports Map

From 'workflow':

  • sleep -- Durable pause (sleep('10s'), sleep('7 days'))
  • FatalError -- Non-retriable error (skips retry)
  • RetryableError -- Explicitly retriable error with optional delay
  • getStepMetadata -- Returns { stepId } for idempotency keys
  • getWorkflowMetadata -- Returns { runId, workflowName } at runtime
  • getWritable -- Access the run's default WritableStream for streaming
  • defineHook -- Type-safe hook for external events
  • createHook -- Low-level hook for arbitrary payloads
  • createWebhook -- HTTP webhook that suspends workflow until called
  • fetch -- HTTP fetch with automatic retry semantics

From 'workflow/api':

  • start -- Start/enqueue a workflow run
  • getRun -- Get run status/result by ID (synchronous, returns Run object)
  • resumeHook -- Resume a suspended hook with payload
  • resumeWebhook -- Resume a webhook with a Request object
  • getHookByToken -- Get hook details by token
  • getWorld -- Direct access to storage/queuing internals

From 'workflow/next':

  • withWorkflow -- Next.js config wrapper (generates routes)

File Organization

workflows/           -- Workflow files with 'use workflow'
app/api/             -- API routes that call start() or getRun()
next.config.ts       -- withWorkflow() wrapper
app/.well-known/     -- Auto-generated by withWorkflow (do NOT edit)

Implementation Guide

Core Directives

'use workflow' -- File-level directive marking a durable workflow function.

export async function myWorkflow(input: string) {
  'use workflow'
  const a = await stepA(input)
  const b = await stepB(a)
  return b
}

'use step' -- Function-level directive marking a durable step.

async function stepA(data: string) {
  'use step'
  // This compiles into an isolated API route.
  // Each step is a durability boundary.
  return await processData(data)
}

Rules:

  • Both directives ONLY work with async function declarations
  • Arrow functions do NOT work: const step = async () => { 'use step' } will silently fail
  • Step functions must be at root level, NOT nested inside other functions
  • Steps compile into isolated API routes -- they cannot access module-scope imports from the workflow file
  • Use await import() for dependencies needed inside step functions

Starting and Monitoring Workflows

Start a workflow from an API route or server action:

import { start } from 'workflow/api'
import { myWorkflow } from '@/workflows/my-workflow'

const run = await start(myWorkflow, ['argument1', 'argument2'])
// run.runId -- unique identifier
// run.status -- Promise<WorkflowRunStatus>
// run.returnValue -- Promise<T>
// run.cancel() -- cancel the run

Check status (note: getRun() is synchronous, returns Run object directly):

import { getRun } from 'workflow/api'

const run = getRun(runId) // NOT async -- returns Run directly
const status = await run.status
// 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'

if (status === 'completed') {
  const result = await run.returnValue
}

Run Object Shape

interface Run<T> {
  runId: string
  status: Promise<WorkflowRunStatus>
  returnValue: Promise<T>
  workflowName: Promise<string>
  createdAt: Promise<Date>
  startedAt: Promise<Date | undefined>
  completedAt: Promise<Date | undefined>
  readable: ReadableStream
  cancel(): Promise<void>
  getReadable(startIndex?: number): ReadableStream
}

Sequential Steps

export async function pipeline(input: string) {
  'use workflow'
  const validated = await validate(input)
  const processed = await process(validated)
  const result = await save(processed)
  return result
}

Parallel Steps

export async function parallelWorkflow(data: Data) {
  'use workflow'
  const [images, text] = await Promise.all([
    processImages(data.images),
    processText(data.text),
  ])
  return await combine(images, text)
}

Durable Sleep

sleep() is durable -- zero compute consumed while waiting. The workflow resumes automatically.

import { sleep } from 'workflow'

export async function reminderWorkflow(userId: string) {
  'use workflow'
  await sendWelcomeEmail(userId)
  await sleep('7 days')
  await sendFollowUpEmail(userId)
  await sleep('30 days')
  await sendRetentionEmail(userId)
}

Accepted formats: '10s', '5m', '2h', '7 days'

Error Handling

import { FatalError, RetryableError } from 'workflow'

async function processPayment(orderId: string) {
  'use step'

  // Validation failure -- don't retry
  if (!orderId) throw new FatalError('Missing order ID')

  // Rate limited -- retry with specific delay
  if (rateLimited) throw new RetryableError('Rate limited', { retryAfter: 30 })

  // Regular errors retry automatically with exponential backoff
  const res = await fetch(url)
  if (!res.ok) throw new Error('Payment API failed') // will retry
}

Default retry behavior: 64 max deliveries, 5s initial retry delay, exponential backoff. Regular throw new Error() triggers automatic retries. FatalError skips retries entirely.

Idempotency

Use getStepMetadata() to get a stable stepId for idempotency keys:

import { getStepMetadata } from 'workflow'

async function chargeUser(userId: string, amount: number) {
  'use step'
  const { stepId } = getStepMetadata()
  await stripe.charges.create(
    { amount, customer: userId },
    { idempotencyKey: stepId }
  )
}

Streaming

Write partial results from steps using getWritable():

import { getWritable } from 'workflow'

async function generateReport() {
  'use step'
  const writable = getWritable()
  const writer = writable.getWriter()
  await writer.write('Processing section 1...\n')
  // ... do work ...
  await writer.write('Processing section 2...\n')
  await writer.close()
}

Read from a run:

const run = await start(myWorkflow, [args])
const reader = run.readable.getReader()
while (true) {
  const { done, value } = await reader.read()
  if (done) break
  console.log(new TextDecoder().decode(value))
}

Resume reading from a specific position with run.getReadable(startIndex).


Advanced Patterns

Human-in-the-Loop (Hooks)

Define a typed hook that suspends the workflow until resumed:

import { defineHook } from 'workflow'

const approvalHook = defineHook<{ approved: boolean }>()

export async function approvalWorkflow(content: string) {
  'use workflow'
  const draft = await generateDraft(content)
  const events = approvalHook.create({ token: draft.id })
  for await (const event of events) {
    if (event.approved) return await publish(draft)
    else return await discard(draft)
  }
}

Resume from an API route:

import { resumeHook } from 'workflow/api'

export async function POST(req: Request) {
  const { token, approved } = await req.json()
  await resumeHook(token, { approved })
  return Response.json({ ok: true })
}

Webhooks

Create an HTTP webhook endpoint that suspends the workflow:

import { createWebhook } from 'workflow'

async function waitForPayment(orderId: string) {
  'use step'
  const webhook = createWebhook()
  await saveWebhookUrl(orderId, webhook.url) // give URL to payment provider
  const payload = await webhook // suspends until called
  return payload
}

Resume from an API route:

import { resumeWebhook } from 'workflow/api'

export async function POST(req: Request) {
  await resumeWebhook(token, req)
  return Response.json({ ok: true })
}

Workflow Metadata

Access run-level metadata from within a workflow or step:

import { getWorkflowMetadata } from 'workflow'

async function logStep() {
  'use step'
  const { runId, workflowName } = getWorkflowMetadata()
  console.log(`Running ${workflowName} [${runId}]`)
}

Durable Fetch

workflow exports its own fetch with automatic retry semantics:

import { fetch } from 'workflow'

async function callExternalAPI() {
  'use step'
  const res = await fetch('https://api.example.com/data')
  return await res.json()
}

Critical Gotchas

  1. Arrow functions silently fail -- 'use step' and 'use workflow' ONLY work with async function declarations. Arrow functions compile without error but steps will not be durable.

  2. Steps are isolated -- Each step compiles into its own API route. Module-scope imports from the workflow file are NOT available inside steps. Use await import() for dependencies.

  3. Steps are stateless -- Do not rely on module-scope variables or closures between steps. Each step runs in a fresh, isolated context. Pass data through return values.

  4. getRun() is synchronous -- It returns a Run object directly (not a Promise). The Run's properties (status, returnValue, createdAt) are Promises you must await individually.

  5. sleep() format -- Accepts human-readable strings: '10s', '5m', '2h', '7 days'. Not milliseconds.

  6. Generated routes -- withWorkflow() auto-generates app/.well-known/workflow/v1/ containing flow/, step/, and webhook/[token]/ routes. Do not edit these.

  7. Retry defaults -- 64 max deliveries, 5s initial delay, exponential backoff. FatalError bypasses retries. RetryableError allows explicit retryAfter in seconds.

  8. serverExternalPackages -- If a dependency used inside a step has bundling issues, add it to nextConfig.serverExternalPackages array.

  9. Monorepo "pending" state -- Workflows stuck in "pending" in turborepo/monorepo setups usually means withWorkflow() is misconfigured. Check build logs and verify the config wrapper is applied.

  10. Dynamic imports in steps -- Since steps are isolated routes, use const { thing } = await import('package') inside step bodies for any non-trivial dependencies.

Install via CLI
npx skills add https://github.com/Pibomeister/just-bash-vercel-ai-sdk-elements --skill workflow
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator