Skip to main content
Workflows use special directives to mark code for transformation by the Workflow DevKit compiler. This page explains the compilation pipeline, the three transformation modes, and how they work together to enable durable execution.

Overview

The Workflow DevKit compiler operates in three distinct modes, transforming the same source code differently for each execution context:

Comparison Table

ModeUsed InPurposeOutput API RouteRequired?
StepBuild timeBundles step handlers.well-known/workflow/v1/stepYes
WorkflowBuild timeBundles workflow orchestrators.well-known/workflow/v1/flowYes
ClientBuild/RuntimeProvides workflow IDs and types to startYour application codeOptional*
* 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.

The Compilation Pipeline

The Workflow DevKit uses an SWC-based compiler plugin (@workflow/swc-plugin) that performs the following steps:

1. Parse and Identify Directives

The compiler scans each function for directive strings at the beginning of the function body:
export async function myWorkflow() {
  "use workflow"; // ← Directive must be first statement
  // workflow code...
}
Valid directive placement:
// ✓ Valid - first statement in function body
export async function myWorkflow() {
  "use workflow";
  const result = await myStep();
  return result;
}

// ✗ Invalid - directive after other statements
export async function badWorkflow() {
  const x = 1;
  "use workflow"; // Too late!
  return x;
}

2. Generate Stable IDs

For each function with a directive, the compiler generates a stable identifier based on the file path and function name: Pattern: {type}//{filepath}//{functionName} Examples:
  • workflow//workflows/user-signup.js//handleUserSignup
  • step//workflows/user-signup.js//createUser
  • step//workflows/payments/checkout.ts//processPayment
Why stable IDs matter:
  • IDs don’t change unless you rename files or functions
  • Each workflow/step has a unique identifier across your application
  • IDs work across different runtimes and deployments
  • The World layer can track function execution history

3. Transform Based on Mode

Depending on the compilation mode, the compiler applies different transformations to the same source code.

Step Mode Transformation

Purpose: Create executable step handlers that run in the full Node.js runtime. Input:
export async function sendEmail(to: string, subject: string) {
  "use step";
  
  await emailClient.send({
    to,
    subject,
    body: "Hello!"
  });
  
  return { sent: true };
}
Output (step.js):
import { registerStepFunction } from "workflow/internal/private";

export async function sendEmail(to: string, subject: string) {
  await emailClient.send({
    to,
    subject,
    body: "Hello!"
  });
  
  return { sent: true };
}

registerStepFunction(
  "step//workflows/email.js//sendEmail",
  sendEmail
);
Transformations applied:
  1. Remove the "use step" directive
  2. Keep the function body completely intact
  3. Call registerStepFunction() to register the step with the runtime
  4. Generate a stable step ID based on file path and function name
No transformation needed for the function body because step functions execute in the full Node.js runtime with access to all APIs, file system, databases, etc.

Workflow Mode Transformation

Purpose: Create deterministic orchestrators that can replay from event logs. Input:
export async function sendEmail(to: string, subject: string) {
  "use step";
  await emailClient.send({ to, subject, body: "Hello!" });
  return { sent: true };
}

export async function welcomeUser(email: string) {
  "use workflow";
  
  await sendEmail(email, "Welcome!");
  
  return { success: true };
}
Output (flow.js):
export async function sendEmail(to: string, subject: string) {
  return globalThis[Symbol.for("WORKFLOW_USE_STEP")](
    "step//workflows/email.js//sendEmail"
  )(to, subject);
}

export async function welcomeUser(email: string) {
  await sendEmail(email, "Welcome!");
  return { success: true };
}
welcomeUser.workflowId = "workflow//workflows/email.js//welcomeUser";
Transformations applied:
  1. Step functions: Replace body with WORKFLOW_USE_STEP call
  2. Workflow functions: Keep body intact, add workflowId property
  3. Remove all directives
Why replace step bodies? During workflow replay, we need to:
  • Check if the step has already been executed (in the event log)
  • Return cached results for completed steps
  • Suspend execution for steps that haven’t completed yet
The WORKFLOW_USE_STEP symbol provides this behavior. From workflow.ts:
const useStep = createUseStep(workflowContext);
vmGlobalThis[WORKFLOW_USE_STEP] = useStep;
Why keep workflow bodies intact? Workflow functions are pure orchestration logic. They need to execute the same way during replay to determine what the next step should be.

Client Mode Transformation

Purpose: Prevent direct workflow execution and provide type-safe workflow references for start(). Input:
export async function welcomeUser(email: string) {
  "use workflow";
  await sendEmail(email, "Welcome!");
  return { success: true };
}
Output (your application code):
export async function welcomeUser(email: string) {
  throw new Error(
    "You attempted to execute workflow 'welcomeUser' directly. " +
    "Workflows must be started using the start() function."
  );
}
welcomeUser.workflowId = "workflow//workflows/email.js//welcomeUser";
Transformations applied:
  1. Replace workflow body with error throw
  2. Add workflowId property
  3. Step functions are not transformed in client mode
Why throw an error? Workflow functions cannot be called directly—they must be started using start(). The error prevents accidental direct execution. Why add workflowId? The start() function reads this property to identify which workflow to launch:
import { start } from "workflow";
import { welcomeUser } from "./workflows/email";

// start() reads welcomeUser.workflowId
const run = await start(welcomeUser, "[email protected]");

Generated Files

When you build your application, the Workflow DevKit generates handler files in .well-known/workflow/v1/:

flow.js

Contains all workflow functions transformed in workflow mode. Structure:
// Bundled workflow code is embedded as a string
const workflowCode = `
  // All workflow functions and dependencies
  export async function welcomeUser(email) {
    await sendEmail(email, "Welcome!");
    return { success: true };
  }
  // ...
`;

// POST handler that executes workflows in a VM
export async function POST(request: Request) {
  const { runId, workflowName, events } = await request.json();
  
  // Execute in Node.js VM for sandboxing
  const result = await runWorkflow(
    workflowCode,
    workflowRun,
    events,
    encryptionKey
  );
  
  return Response.json({ result });
}
VM Sandbox: Workflow code runs in a Node.js VM context to ensure:
  • Determinism: Same inputs always produce same outputs
  • Side-effect prevention: Direct access to Node.js APIs is blocked
  • Isolation: Workflow logic is isolated from the main runtime
Build-time validation: The compiler validates workflows during 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 before deployment

step.js

Contains all step functions transformed in step mode. Structure:
import { registerStepFunction } from "workflow/internal/private";

export async function sendEmail(to, subject) {
  await emailClient.send({ to, subject, body: "Hello!" });
  return { sent: true };
}

registerStepFunction(
  "step//workflows/email.js//sendEmail",
  sendEmail
);

// POST handler that executes individual steps
export async function POST(request: Request) {
  const { stepId, args } = await request.json();
  
  // Look up registered step function
  const stepFn = getStepFunction(stepId);
  
  // Execute with full runtime access
  const result = await stepFn(...args);
  
  return Response.json({ result });
}

webhook.js

Contains webhook handling logic for delivering external data to running workflows. Structure varies by framework:
  • Next.js: Generates webhook/[token]/route.js (leverages App Router dynamic routing)
  • Other frameworks: Generates webhook.js or webhook.mjs handler

Determinism Enforcement

The compiler and runtime work together to enforce deterministic execution:

Compiler-Level Enforcement

Blocked imports in workflows:
export async function badWorkflow() {
  "use workflow";
  
  // Build error: Node.js module in workflow
  const crypto = require("crypto");
  return crypto.randomBytes(16);
}
Error: Node.js module in workflow with link to error documentation.

Runtime-Level Enforcement

From workflow.ts, the VM overrides non-deterministic APIs:
// Block global fetch
vmGlobalThis.fetch = () => {
  throw new vmGlobalThis.Error(
    'Global "fetch" is unavailable in workflow functions. ' +
    'Use the "fetch" step function from "workflow".'
  );
};

// Block timeout functions
vmGlobalThis.setTimeout = () => {
  throw new WorkflowRuntimeError(
    'Timeout functions are not supported in workflow functions. ' +
    'Use the "sleep" function from "workflow".'
  );
};

// Provide seeded random
const { context, globalThis: vmGlobalThis } = createContext({
  seed: workflowRun.runId,
  fixedTimestamp: +startedAt,
});
Deterministic APIs provided:
  • Math.random(): Seeded based on run ID
  • Date.now(): Fixed timestamp that updates with event replay
  • Custom Request/Response classes with step-based body parsing

Closure Variable Handling

Step functions can close over variables from their defining scope. The compiler captures these automatically: Input:
export async function processOrder(orderId: string) {
  "use workflow";
  
  const config = { timeout: 5000 };
  
  async function fetchOrder() {
    "use step";
    // Closes over 'config' and 'orderId'
    return await api.get(`/orders/${orderId}`, config);
  }
  
  return await fetchOrder();
}
The compiler generates:
const fetchOrder = useStep(
  "step//workflows/order.js//fetchOrder",
  () => ({ config, orderId }) // Closure variables function
);
From step.ts, the useStep implementation serializes closure variables:
const queueItem: StepInvocationQueueItem = {
  type: 'step',
  correlationId,
  stepName,
  args,
};

// Invoke the closure variables function to get the closure scope
const closureVars = closureVarsFn?.();
if (closureVars) {
  queueItem.closureVars = closureVars;
}
These variables are serialized along with the step arguments and restored when the step executes.

Framework Integration

The compilation process is framework-agnostic—it outputs standard JavaScript that works anywhere. For users: Your framework handles all transformations automatically. See the Getting Started guide for your framework. For framework authors: The @workflow/swc-plugin can be integrated into any build pipeline:
import { transform } from "@swc/core";
import workflowPlugin from "@workflow/swc-plugin";

const result = await transform(code, {
  plugin: (m) => workflowPlugin(m, {
    mode: "workflow", // or "step" or "client"
  }),
});

Debugging Transformed Code

If you need to debug transformation issues:
  1. Look in .well-known/workflow/v1/: Check the generated flow.js, step.js, and webhook.js 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
Common issues:
  • Directive not first statement: Move directive to the very beginning of the function body
  • Node.js import in workflow: Move the import to a step function instead
  • Missing workflowId: Ensure the function is exported and has the directive

Conclusion

The Workflow DevKit’s compilation process transforms directive-annotated code into three execution contexts:
  • Step mode: Full runtime access for side effects
  • Workflow mode: Sandboxed orchestration for deterministic replay
  • Client mode: Type-safe references for starting workflows
This multi-mode transformation enables durable execution, deterministic replay, and type safety—all while maintaining familiar JavaScript syntax and semantics.

Build docs developers (and LLMs) love