---
title: Run dynamic code in a durable workflow
description: Run dynamic JavaScript with Run SDK and Workflow SDK. Pause a local refund demo for human approval and resume it after a server restart.
url: "https://vercel.com/kb/guide/run-dynamic-code-in-a-durable-workflow"
published: 2026-10-22
last_updated: 2026-10-22
authors: Aayush Kapoor
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

Use [Run SDK](https://github.com/vercel-labs/run) and [Workflow SDK](https://workflow-sdk.dev/) to execute dynamic JavaScript with an approval step before a refund. Run SDK runs the code with explicitly provided host functions, and Workflow SDK saves its continuation while waiting for the user's decision.

The demo uses [Hono](https://hono.dev/) for API routes and [Nitro](https://nitro.build/) with `workflow/nitro` to run the server. Workflow SDK persists pending runs locally across restarts.

## How the two SDKs work together

Each SDK owns one side of the problem:

| Concern   | Run SDK                                                                                                                       | Workflow SDK                                                     |
| --------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| Execution | Runs the dynamic source in a sandbox with no access to the filesystem, environment variables, application modules, or network | Runs trusted application code as durable steps                   |
| State     | Returns a signed continuation when the source is interrupted                                                                  | Saves step results and stores the continuation between rounds    |
| Waiting   | Interrupts before a protected action and returns immediately                                                                  | Suspends at an approval hook and resumes when a decision arrives |

When the dynamic source calls the protected `orders.refund()` function, the two SDKs hand off to each other:

1. Run SDK interrupts before the action and returns a signed continuation.
   
2. Workflow SDK stores the continuation, suspends the workflow at an approval hook, and resumes when a decision arrives.
   
3. Run SDK replays the source, restores completed host-function calls from the continuation, and continues at the refund request.
   

## Prerequisites

You will need:

- Node.js 22.13 or later
  
- pnpm
  

## 1\. Create the Hono and Nitro application

Install the versions used by the linked example, including Workflow SDK 4.8.5 and Nitro 3 beta. Keep these versions together for this tutorial; Workflow SDK 5 changes the hook metadata API used by the copied handlers.

```bash
mkdir durable-order-automation
cd durable-order-automation
pnpm init
pnpm add --save-exact hono@4.13.5 nitro@3.0.260610-beta rollup@4.61.1 run@2.1.10 workflow@4.8.5
pnpm add --save-dev --save-exact typescript@5.8.3 @types/node@22.19.19
```

Update the scripts and module type in `package.json`:

```json
{
  "name": "durable-order-automation",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "nitro dev",
    "build": "nitro build",
    "type-check": "tsc --noEmit"
  }
}
```

Keep the dependency fields created by the `pnpm add` commands; the snippet above shows only the project fields you need to add or update.

Create `tsconfig.json` with the Workflow SDK TypeScript plugin:

```json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["node"],
    "plugins": [{ "name": "workflow" }]
  },
  "include": ["src/**/*.ts", "*.ts"]
}
```

Create `nitro.config.ts` to load Workflow SDK and route requests through Hono:

```typescript
import { defineConfig } from 'nitro';

export default defineConfig({
  modules: ['workflow/nitro'],
  routes: {
    '/**': './src/app.ts',
  },
});
```

Create a signing secret for Run SDK continuations:

```bash
openssl rand -base64 32
```

Add the generated value to `.env`:

```bash
RUN_CONTINUATION_SECRET="replace-with-the-generated-value"
```

Create the directories used in the rest of the guide:

```bash
mkdir -p public src/api src/domain src/sandbox src/steps src/workflow
```

### Project structure

The example keeps each execution boundary in its own directory:

```text
durable-order-automation/
├── nitro.config.ts
├── public/
│   ├── index.html
│   ├── app.js
│   └── styles.css
└── src/
    ├── app.ts
    ├── api/
    ├── domain/
    ├── sandbox/
    │   ├── example-source.ts
    │   ├── create-order-runner.ts
    │   └── order-host-functions.ts
    ├── steps/
    │   └── run-automation-round.ts
    └── workflow/
        └── order-automation-workflow.ts
```

Each directory maps to one responsibility:

| Directory       | Responsibility                                                                   |
| --------------- | -------------------------------------------------------------------------------- |
| `src/workflow/` | Durable orchestration: loops through sandbox rounds and waits on approval        |
| `src/steps/`    | Retryable server work that runs the sandbox and returns serializable results     |
| `src/sandbox/`  | The dynamic source, the runner configuration, and the host functions it may call |
| `src/api/`      | Hono routes that start automations, report status, and resume workflows          |
| `src/domain/`   | The order store and the serializable types shared across boundaries              |
| `public/`       | The browser interface, which never touches either SDK                            |

## 2\. Define the dynamic refund code

Save this example source string in `src/sandbox/example-source.ts`:

```typescript
export const exampleSource = `
  const order = await orders.get('order_123');

  if (order.total > 100) {
    const refund = await orders.refund(order.id, 25);

    if (!refund.approved) {
      return { status: 'refund_rejected', orderId: order.id };
    }
  }

  return { status: 'complete', orderId: order.id };
`;
```

You can change the condition or refund amount before starting a run. Regardless of where the string came from, Run SDK treats it as dynamic source rather than trusted application code.

## 3\. Configure the runner

Configure signing and sandbox limits in `src/sandbox/create-order-runner.ts`:

```typescript
import { createRunner, createSignedContinuationCodec } from 'run';

export const createOrderRunner = () => {
  const secret = process.env.RUN_CONTINUATION_SECRET;
  if (!secret) {
    throw new Error('RUN_CONTINUATION_SECRET is required.');
  }

  return createRunner({
    continuationAudience: 'durable-order-automation-v1',
    continuationCodec: createSignedContinuationCodec({
      secret,
      maxAgeMs: 30 * 24 * 60 * 60 * 1000,
    }),
    limits: {
      timeoutMs: 10_000,
      memoryLimitBytes: 32 * 1024 * 1024,
    },
  });
};
```

Each option controls one part of the continuation contract:

| Option                 | Purpose                                                                                                                                             |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `continuationAudience` | Ties continuations to this application version. Keep it stable for as long as a continuation may be resumed.                                        |
| `continuationCodec`    | Signs continuations so they can't be forged or altered. `maxAgeMs` makes each token expire 30 days after issuance; resuming an expired token fails. |
| `limits`               | Caps execution time and memory for each sandbox run.                                                                                                |

Token expiry does not cancel the workflow's pending approval hook.

Pending continuations need the same signing secret and audience. Run SDK also checks the source, host-function names, and continuation context before replay.

## 4\. Expose the order capabilities

The dynamic source calls `orders.get()` and `orders.refund()`. The host application implements and explicitly passes these functions into the sandbox.

Create `src/sandbox/order-host-functions.ts` with host functions that validate input and interrupt before issuing a refund:

```typescript
import { getHostFunctionContext, type HostFunctions } from 'run';
import { orderStore } from '../domain/order-store.js';
import type { AutomationScope } from '../domain/types.js';

interface RefundResolution {
  approved: boolean;
  decidedBy: string;
}

const parseRefundResolution = (value: unknown): RefundResolution => {
  if (typeof value !== 'object' || value === null) {
    throw new Error('Refund resolution must be an object.');
  }
  const candidate = value as Partial<RefundResolution>;
  if (
    typeof candidate.approved !== 'boolean' ||
    typeof candidate.decidedBy !== 'string'
  ) {
    throw new Error('Refund resolution is invalid.');
  }
  return {
    approved: candidate.approved,
    decidedBy: candidate.decidedBy,
  };
};

export const createOrderHostFunctions = (
  scope: AutomationScope,
): HostFunctions => ({
  orders: {
    get: async (orderId: string) => {
      if (typeof orderId !== 'string' || orderId.length === 0) {
        throw new TypeError('orderId must be a non-empty string.');
      }
      return await orderStore.getForTenant(scope.tenantId, orderId);
    },

    refund: async (orderId: string, amount: number) => {
      if (typeof orderId !== 'string' || orderId.length === 0) {
        throw new TypeError('orderId must be a non-empty string.');
      }
      if (
        typeof amount !== 'number' ||
        !Number.isFinite(amount) ||
        amount <= 0
      ) {
        throw new TypeError('amount must be a positive finite number.');
      }

      const context = getHostFunctionContext();

      // Interrupt before the protected side effect. Do not catch this signal.
      if (context.resume === undefined) {
        context.interrupt({
          kind: 'refund-approval',
          action: 'refund',
          orderId,
          amount,
        });
      }

      const resume = context.resume;
      if (resume === undefined) {
        throw new Error('Refund host function resumed without a resolution.');
      }

      const resolution = parseRefundResolution(resume.resolution);
      if (!resolution.approved) {
        return { approved: false, refunded: false };
      }

      return await orderStore.refundOnce({
        tenantId: scope.tenantId,
        orderId,
        amount,
        idempotencyKey: [context.logicalRunId, resume.interruptionId].join(':'),
      });
    },
  },
});
```

`context.interrupt()` stops the sandbox before the refund is sent. When the run resumes, the same host function receives the approval as `context.resume.resolution` and validates its shape before acting on it.

The refund still uses an idempotency key because a workflow step can be retried after an external service accepts a request, but before the step result is saved.

The example's [`src/domain/order-store.ts`](https://github.com/vercel-labs/run/blob/f6709eb25a230cbfd3fc9fe2394eff32c21ee0cc/examples/durable-order-automation/src/domain/order-store.ts) provides the small local order adapter used by these host functions. Add it alongside [`src/domain/types.ts`](https://github.com/vercel-labs/run/blob/f6709eb25a230cbfd3fc9fe2394eff32c21ee0cc/examples/durable-order-automation/src/domain/types.ts), which defines `AutomationScope` and the other serializable messages passed between Run SDK, Workflow SDK, and the API.

## 5\. Execute Run SDK inside a durable step

Create `src/steps/run-automation-round.ts` from the complete implementation below. It builds the runner inside a durable step and returns serializable outcomes for completion, interruption, or failure.

```typescript
import { RunError, type RunInterruption, type RunResolution } from 'run';
import type {
  ApprovalRequest,
  AutomationScope,
  RunRoundOutcome,
} from '../domain/types.js';
import { createOrderRunner } from '../sandbox/create-order-runner.js';
import { createOrderHostFunctions } from '../sandbox/order-host-functions.js';

interface RunAutomationRoundInput {
  source: string;
  scope: AutomationScope;
  continuation?: string;
  resolutions?: RunResolution[];
}

const parseApprovalRequest = (
  interruption: RunInterruption,
): ApprovalRequest => {
  if (
    typeof interruption.payload !== 'object' ||
    interruption.payload === null
  ) {
    throw new Error('Refund interruption payload must be an object.');
  }
  const payload = interruption.payload as Record<string, unknown>;
  if (
    payload.kind !== 'refund-approval' ||
    payload.action !== 'refund' ||
    typeof payload.orderId !== 'string' ||
    typeof payload.amount !== 'number'
  ) {
    throw new Error('Refund interruption payload is invalid.');
  }
  return {
    id: interruption.id,
    hostFunctionName: interruption.hostFunctionName,
    action: 'refund',
    orderId: payload.orderId,
    amount: payload.amount,
  };
};

const retryableRunCodes = new Set(['RUN_ABORTED', 'RUN_CONCURRENCY_LIMIT']);

export async function runAutomationRound(
  input: RunAutomationRoundInput,
): Promise<RunRoundOutcome> {
  'use step';

  try {
    const result = await createOrderRunner().run({
      source: input.source,
      hostFunctions: createOrderHostFunctions(input.scope),
      continuationContext: input.scope,
      continuation: input.continuation,
      resolutions: input.resolutions,
    });

    if (result.status === 'completed') {
      return { status: 'completed', value: result.value };
    }

    try {
      return {
        status: 'interrupted',
        continuation: result.continuation,
        interruptions: result.interruptions.map(parseApprovalRequest),
      };
    } catch (error) {
      return {
        status: 'failed',
        error: {
          code: 'INVALID_APPROVAL_REQUEST',
          message:
            error instanceof Error
              ? error.message
              : 'Approval request is invalid.',
        },
      };
    }
  } catch (error) {
    if (!RunError.isInstance(error) || retryableRunCodes.has(error.code)) {
      throw error;
    }
    return {
      status: 'failed',
      error: { code: error.code, message: error.message },
    };
  }
}
```

Each call to `runAutomationRound()` is one durable step. The initial call has no continuation, while each later call receives the continuation and a resolution for every pending interruption.

## 6\. Wait for an approval decision

Create `src/workflow/order-automation-workflow.ts`. It repeats Run SDK rounds until the source completes or returns a failure:

```typescript
import { createHook, getWorkflowMetadata, type HookOptions } from 'workflow';
import {
  createRunResolutions,
  type ApprovalBatch,
  type ApprovalHookMetadata,
  type AutomationInput,
  type RunRoundOutcome,
} from '../domain/types.js';
import { publishApprovalRequest } from '../steps/publish-approval-request.js';
import { runAutomationRound } from '../steps/run-automation-round.js';

export async function orderAutomationWorkflow(
  input: AutomationInput,
): Promise<RunRoundOutcome> {
  'use workflow';

  const { workflowRunId } = getWorkflowMetadata();
  let outcome = await runAutomationRound({
    source: input.source,
    scope: input.scope,
  });
  let round = 1;

  while (outcome.status === 'interrupted') {
    const metadata: ApprovalHookMetadata = {
      kind: 'order-approval',
      automationKey: input.automationKey,
      tenantId: input.scope.tenantId,
      round,
      requests: outcome.interruptions,
    };

    using approval = createHook<ApprovalBatch>({
      token: input.approvalHookToken,
      metadata: metadata as unknown as HookOptions['metadata'],
    });

    await publishApprovalRequest(metadata, approval.token, workflowRunId);
    const decision = await approval;
    approval.dispose();

    outcome = await runAutomationRound({
      source: input.source,
      scope: input.scope,
      continuation: outcome.continuation,
      resolutions: createRunResolutions(outcome.interruptions, decision),
    });
    round += 1;
  }

  return outcome;
}
```

At `await approval`, the workflow suspends without keeping a server process waiting in memory. After a decision arrives, dispose of that hook before starting the next sandbox round so another protected action can open a new approval round.

`createRunResolutions()` in `src/domain/types.ts` matches each decision in the approval batch to its pending interruption. It rejects missing, duplicate, or unknown decisions and returns the `{ interruptionId, value }` objects Run SDK expects.

Add [`src/steps/publish-approval-request.ts`](https://github.com/vercel-labs/run/blob/f6709eb25a230cbfd3fc9fe2394eff32c21ee0cc/examples/durable-order-automation/src/steps/publish-approval-request.ts) for local approval notifications. This demo logs the hook token to the terminal; remove that logging when replacing it with a production notification adapter.

## 7\. Add the Hono API

The Hono application exposes four routes used by the UI:

| Route                                     | Method | Purpose                                             |
| ----------------------------------------- | ------ | --------------------------------------------------- |
| `/api/example-source`                     | GET    | Returns the preloaded dynamic source for the editor |
| `/api/automations`                        | POST   | Validates the source and starts the workflow        |
| `/api/automations/:automationId`          | GET    | Reports the running, waiting, or completed state    |
| `/api/automations/:automationId/decision` | POST   | Verifies the decision and resumes the approval hook |

Create `src/app.ts` to connect these routes to the API handlers and return JSON errors. The local demo also exposes `/api/orders/stats` for inspecting store calls:

```typescript
import { Hono } from 'hono';
import { HttpError } from './api/auth.js';
import { getAutomation } from './api/get-automation.js';
import { startAutomation } from './api/start-automation.js';
import { submitDecision } from './api/submit-decision.js';
import { orderStore } from './domain/order-store.js';
import { exampleSource } from './sandbox/example-source.js';

const app = new Hono();

app.get('/api/example-source', c => c.json({ source: exampleSource }));

app.post('/api/automations', async c => await startAutomation(c.req.raw));

app.get('/api/automations/:automationId', async c => {
  const runId = c.req.query('runId');
  if (!runId) throw new HttpError(400, 'runId is required.');
  return await getAutomation(c.req.raw, c.req.param('automationId'), runId);
});

app.post(
  '/api/automations/:automationId/decision',
  async c => await submitDecision(c.req.raw, c.req.param('automationId')),
);

app.get('/api/orders/stats', async c => c.json(await orderStore.stats()));

app.onError((error, c) => {
  if (error instanceof HttpError) {
    return c.json(
      { error: error.message },
      error.status as 400 | 403 | 404 | 413,
    );
  }
  console.error(error);
  return c.json({ error: 'Internal server error.' }, 500);
});

export default app;
```

`startAutomation()` starts `orderAutomationWorkflow` with `start()` from `workflow/api`. `getAutomation()` reads the workflow and hook state for the UI. `submitDecision()` verifies the approval belongs to the same automation before calling `resumeHook()`.

Create the API handlers from the example:

- [`src/api/start-automation.ts`](https://github.com/vercel-labs/run/blob/f6709eb25a230cbfd3fc9fe2394eff32c21ee0cc/examples/durable-order-automation/src/api/start-automation.ts) validates the source and starts the workflow.
  
- [`src/api/get-automation.ts`](https://github.com/vercel-labs/run/blob/f6709eb25a230cbfd3fc9fe2394eff32c21ee0cc/examples/durable-order-automation/src/api/get-automation.ts) returns the running, waiting, or completed state.
  
- [`src/api/submit-decision.ts`](https://github.com/vercel-labs/run/blob/f6709eb25a230cbfd3fc9fe2394eff32c21ee0cc/examples/durable-order-automation/src/api/submit-decision.ts) validates the decision and resumes the approval.
  
- [`src/api/auth.ts`](https://github.com/vercel-labs/run/blob/f6709eb25a230cbfd3fc9fe2394eff32c21ee0cc/examples/durable-order-automation/src/api/auth.ts), [`automation-identity.ts`](https://github.com/vercel-labs/run/blob/f6709eb25a230cbfd3fc9fe2394eff32c21ee0cc/examples/durable-order-automation/src/api/automation-identity.ts), and [`read-bounded-json.ts`](https://github.com/vercel-labs/run/blob/f6709eb25a230cbfd3fc9fe2394eff32c21ee0cc/examples/durable-order-automation/src/api/read-bounded-json.ts) provide the demo's request boundary and signed automation identifiers.
  

The example uses an `x-demo-role` header as a local substitute for authentication. Replace it with your application's session and authorization checks before using this pattern in production.

## 8\. Connect the browser interface

The UI is made from three static files:

- [`public/index.html`](https://github.com/vercel-labs/run/blob/f6709eb25a230cbfd3fc9fe2394eff32c21ee0cc/examples/durable-order-automation/public/index.html) defines the markup for the source editor, approval panel, result panel, and run timeline.
  
- [`public/app.js`](https://github.com/vercel-labs/run/blob/f6709eb25a230cbfd3fc9fe2394eff32c21ee0cc/examples/durable-order-automation/public/app.js) starts the automation, polls its status, renders approval requests, and submits decisions.
  
- [`public/styles.css`](https://github.com/vercel-labs/run/blob/f6709eb25a230cbfd3fc9fe2394eff32c21ee0cc/examples/durable-order-automation/public/styles.css) lays out the two panels and styles each workflow state.
  

Create the files under `public`. The browser calls the Hono API and uses the automation and workflow run IDs to reconnect. Both SDKs run on the server, which keeps approval hook tokens and Run SDK continuations.

## 9\. Try the approval flow

Check the assembled project, then start the development server:

```bash
pnpm type-check
pnpm build
pnpm dev
```

Test the approval flow:

1. Open localhost:3000.
   
2. Review the preloaded source and select **Run automation**.
   
3. Wait for the refund approval card to appear.
   
4. Choose **Approve** or **Reject**, then select **Submit decision**.
   
5. Confirm that the workflow completes. The dynamic result is `complete` after approval or `refund_rejected` after rejection.
   

## 10\. Test the restart behavior

In development, Workflow SDK persists workflow state in the `.workflow-data` directory, which is what allows a run to survive a restart.

Test resumption across a server restart:

1. Start an automation and leave it waiting for approval.
   
2. Stop the development server.
   
3. Start it again from the same project directory, keeping `.workflow-data` and the signing secret unchanged.
   
4. Reload the same browser URL.
   
5. Approve or reject the refund and confirm that the workflow completes.
   

Workflow SDK restores the workflow and pending hook. Run SDK replays `orders.get()` from the continuation's recorded data without another store call.

Check the terminal output: it should show `orders.get` before the wait, with no second read after resumption. An approval logs `orders.refund` once; a rejection does not issue a refund.

## Production considerations

The included order store and role headers are designed for a local demonstration. For a production implementation:

- Authenticate users before starting, reading, or approving an automation.
  
- Check tenant and workflow ownership before resuming a hook.
  
- Validate every argument received by a host function.
  
- Use transactional writes and provider idempotency keys for refunds.
  
- Keep hook tokens and Run SDK continuations on the server and out of logs.
  
- Keep the source, host-function names, continuation context, audience, and signing configuration stable during resumption.
  

Signed Run SDK continuations provide integrity, not encryption. Do not put secrets or sensitive customer data in dynamic source, host-function results, interruption payloads, or continuation context.

## Next steps

- Review the [durable order automation example](https://github.com/vercel-labs/run/tree/f6709eb25a230cbfd3fc9fe2394eff32c21ee0cc/examples/durable-order-automation).
  
- Learn more about [Workflow SDK](https://workflow-sdk.dev/).
  
- Read the [Run SDK announcement](https://vercel.com/blog/introducing-run).
  
- For workloads that need an operating system, package installation, or process-level isolation, use [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox).

## More Vercel Workflows guides

- [Running Durable Agents on Vercel](/kb/guide/durable-ai-agents): Design durable AI agents on Vercel with patterns for execution recovery, human approval, safe retries, deployment changes, and persistent files.
- [Run recurring security reviews with deepsec on Vercel](/kb/guide/deepsec-reviews-on-vercel): Learn how to run periodic security scans on your code with deepsec. Set up deepsec to review GitHub repositories and deliver findings to GitHub and Slack using Vercel Connect, Workflows, Sandbox, and AI Gateway.
- [How to handle order-ahead traffic spikes on Vercel](/kb/guide/handle-order-ahead-traffic-spikes-on-vercel): Order-ahead traffic spikes hit the same routes every lunch hour. Learn how to scale, cache, queue, and test a restaurant ordering system on Vercel.