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

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](/docs/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
