Durable dynamic workflows
Run dynamic code durably with Workflow SDK steps, hooks, and Run continuations.
Durable dynamic workflows
Run can pause sandboxed code and continue it later. The Workflow SDK makes that pause durable, so the code can wait for approval without keeping a server or function alive.
This guide follows the refund flow in
examples/durable-order-automation.
The application runs user-provided order automation, pauses before issuing a
refund, and waits for an approver.
How it works
- Workflow starts Run.
- Sandboxed code requests a refund.
- Run pauses and returns a continuation.
- Workflow waits for approval.
- Approval resumes the workflow.
- Workflow passes the decision and continuation back to Run.
- Run continues and performs the refund.
There are two layers of durability:
- Workflow saves step results and the pending approval hook. It can stop running while it waits and replay the workflow after the hook is resumed.
- Run returns a continuation when sandboxed code is interrupted. On resume, it starts the source again and reuses host-call results recorded in that continuation.
After a restart, Workflow restores the orchestration and Run restores the sandbox's progress. Together, they avoid repeating completed work or losing the pending refund.
1. Configure
pnpm add run workflow
export RUN_CONTINUATION_SECRET="$(openssl rand -base64 32)"The example creates the runner inside the step that uses it:
export const createOrderRunner = () =>
createRunner({
continuationAudience: 'durable-order-automation-v1',
continuationCodec: createSignedContinuationCodec({
secret: process.env.RUN_CONTINUATION_SECRET!,
maxAgeMs: 30 * 24 * 60 * 60 * 1000,
}),
limits: {
timeoutMs: 10_000,
memoryLimitBytes: 32 * 1024 * 1024,
},
});Keep the secret and audience stable for the lifetime of a continuation. The source, host-function names, and continuation context must also be the same when the run resumes.
2. Expose host functions
The dynamic source can only call functions the application exposes. In this example, it can read an order and request a refund:
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 };Database access stays in trusted host code. Validate every argument at this boundary: sandboxed JavaScript is not constrained by TypeScript types, and arguments and return values must be serializable.
3. Pause before refunding
The orders.refund host function interrupts the run before it changes
anything:
const context = getHostFunctionContext();
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 orderStore.refundOnce({
tenantId: scope.tenantId,
orderId,
amount,
idempotencyKey: [context.logicalRunId, resume.interruptionId].join(':'),
});interrupt() returns a signed continuation to the caller. That continuation
contains Run's replay state, including completed host calls, but the refund has
not happened yet. Do not catch the internal interrupt signal in the host
function.
The stable idempotency key is still important. Workflow steps can retry after a worker failure, including a failure that happens after an external service accepts a write.
4. Run a durable step
export async function runAutomationRound(input: RunAutomationRoundInput) {
'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 };
}
return {
status: 'interrupted',
continuation: result.continuation,
interruptions: result.interruptions.map(parseApprovalRequest),
};
} catch (error) {
if (!RunError.isInstance(error) || retryableRunCodes.has(error.code)) {
throw error;
}
return {
status: 'failed',
error: { code: error.code, message: error.message },
};
}
}Runner instances and host functions are not serializable, so they are created inside the step. The step returns plain data that Workflow can persist.
In the complete example, retryable infrastructure errors are thrown so Workflow can retry the step. Sandbox errors and invalid approval requests are returned as terminal failures.
5. Wait for approval
The workflow runs a round, creates a hook when Run is interrupted, and waits:
let outcome = await runAutomationRound({
source: input.source,
scope: input.scope,
});
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;
outcome = await runAutomationRound({
source: input.source,
scope: input.scope,
continuation: outcome.continuation,
resolutions: createRunResolutions(outcome.interruptions, decision),
});
round += 1;
}At await approval, the workflow is suspended. There is no process waiting in
memory. The Workflow service stores the hook and the results of completed
steps, including the interrupted outcome and its Run continuation.
The loop matters because the resumed source may reach another protected operation and need another approval.
6. Resume securely
The example's approval endpoint checks the actor and the hook metadata before resuming the workflow:
const actor = requireActor(request, 'approver');
const { automationKey, runId } = requireAutomationOwner(
automationId,
actor.tenantId,
);
const token = createApprovalHookToken(automationKey);
const hook = await getHookByToken(token);
const metadata: unknown = hook.metadata;
if (
!isApprovalHookMetadata(metadata) ||
metadata.automationKey !== automationKey ||
metadata.tenantId !== actor.tenantId ||
hook.runId !== runId
) {
throw new HttpError(403, 'You cannot approve this automation.');
}
const batch = {
decisions: parseDecisions(body.decisions),
decidedBy: actor.userId,
};
createRunResolutions(metadata.requests, batch);
await resumeHook(token, batch);An interrupted run can contain more than one interruption. The example rejects
missing, duplicate, and unknown interruption IDs before it calls resumeHook().
Once the hook is resumed, Workflow replays the workflow function. Completed
steps return their saved results. The next runAutomationRound receives the
continuation and approval resolutions. Run starts the source again, serves the
earlier orders.get result from its continuation ledger, and resumes
orders.refund with the decision. Only then can the refund run.
Protect replay state
Run continuations and Workflow hook tokens are bearer values. Store and send them only through authenticated paths, and do not log them in production. Signed continuations prevent tampering; they do not encrypt their contents.
For more control over replay data and one-time consumption, use a storage-backed continuation codec. Set an expiry, rotate signing keys, and avoid putting sensitive data in source, arguments, interruption payloads, or continuation context.
When to use it
Use this setup when sandboxed JavaScript or TypeScript needs to wait for a human decision or take part in a workflow that may retry or restart.
Use Run alone for short sandboxed computations that do not need durable waits. Use a full isolated environment instead when code needs an operating system, package installation, subprocesses, or languages other than JavaScript.