Skip to content
Docs

Run dynamic code in a durable workflow

Run dynamic JavaScript with Run SDK and Workflow SDK. Pause a local refund demo for human approval and resume it after a server restart.

Aayush KapoorSoftware Engineer, AI SDK

Use Run SDK and Workflow SDK 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 for API routes and Nitro with workflow/nitro to run the server. Workflow SDK persists pending runs locally across restarts.

Copy link to headingHow the two SDKs work together

Each SDK owns one side of the problem:

ConcernRun SDKWorkflow SDK
ExecutionRuns the dynamic source in a sandbox with no access to the filesystem, environment variables, application modules, or networkRuns trusted application code as durable steps
StateReturns a signed continuation when the source is interruptedSaves step results and stores the continuation between rounds
WaitingInterrupts before a protected action and returns immediatelySuspends 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.

Copy link to headingPrerequisites

You will need:

  • Node.js 22.13 or later
  • pnpm

Copy link to heading1. 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.

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:

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

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

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

Create a signing secret for Run SDK continuations:

openssl rand -base64 32

Add the generated value to .env:

.env
RUN_CONTINUATION_SECRET="replace-with-the-generated-value"

Create the directories used in the rest of the guide:

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

Copy link to headingProject structure

The example keeps each execution boundary in its own directory:

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:

DirectoryResponsibility
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

Copy link to heading2. Define the dynamic refund code

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

src/sandbox/example-source.ts
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.

Copy link to heading3. Configure the runner

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

src/sandbox/create-order-runner.ts
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:

OptionPurpose
continuationAudienceTies continuations to this application version. Keep it stable for as long as a continuation may be resumed.
continuationCodecSigns continuations so they can't be forged or altered. maxAgeMs makes each token expire 30 days after issuance; resuming an expired token fails.
limitsCaps 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.

Copy link to heading4. 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:

src/sandbox/order-host-functions.ts
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 provides the small local order adapter used by these host functions. Add it alongside src/domain/types.ts, which defines AutomationScope and the other serializable messages passed between Run SDK, Workflow SDK, and the API.

Copy link to heading5. 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.

src/steps/run-automation-round.ts
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.

Copy link to heading6. 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:

src/workflow/order-automation-workflow.ts
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 for local approval notifications. This demo logs the hook token to the terminal; remove that logging when replacing it with a production notification adapter.

Copy link to heading7. Add the Hono API

The Hono application exposes four routes used by the UI:

RouteMethodPurpose
/api/example-sourceGETReturns the preloaded dynamic source for the editor
/api/automationsPOSTValidates the source and starts the workflow
/api/automations/:automationIdGETReports the running, waiting, or completed state
/api/automations/:automationId/decisionPOSTVerifies 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:

src/app.ts
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:

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.

Copy link to heading8. Connect the browser interface

The UI is made from three static files:

  • public/index.html defines the markup for the source editor, approval panel, result panel, and run timeline.
  • public/app.js starts the automation, polls its status, renders approval requests, and submits decisions.
  • 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.

Copy link to heading9. Try the approval flow

Check the assembled project, then start the development server:

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.

Copy link to heading10. 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.

Copy link to headingProduction 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.

Copy link to headingNext steps

Related documentation

More Edgenivo Workflows guides