Continuations
Implement interrupt-and-resume flows and protect their replay state.
Continuations
A continuation lets the host stop an invocation at an interruption and resume the same logical run later. Use it for approvals, authentication, and other decisions made outside the sandbox.
Concept
An interruption does not leave a worker suspended. The invocation ends and
returns a continuation token. When the host supplies that token and a
resolution, run starts a new invocation and replays the source.
Suspension is a host operation, not a rejected promise inside the guest. It
discards the guest context without executing guest catch or finally
handlers. On resume, those handlers follow normal JavaScript semantics as the
source replays and the interrupted host call receives its resolution. Put
cleanup for work owned by the host in the host function; guest finally is not
a suspension or cancellation hook.
Continuations do not turn run into a complete durable execution system. The
host application remains responsible for storing tokens, collecting decisions,
scheduling resumed work, authorizing callers, and handling retries.
Configuration
The simplest configuration uses a shared signing secret:
import { createRunner } from 'run';
const runner = createRunner({
continuationSecret: process.env.RUN_CONTINUATION_SECRET!,
continuationAudience: 'message-approval-v1',
});The secret must contain at least 32 bytes. Every process that can create or resume a continuation must use the same secret and audience.
The convenience run() function reads RUN_CONTINUATION_SECRET
automatically. Continuation configuration is not needed for an invocation that
completes without interruption, but a secret or codec must be available before
a run can interrupt or resume.
Implementation
First, define a host function that interrupts before performing an action:
import { getHostFunctionContext } from 'run';
const hostFunctions = {
messages: {
send: async (recipient: string, body: string) => {
const context = getHostFunctionContext();
if (context.resume === undefined) {
context.interrupt({
kind: 'approval',
recipient,
body,
});
}
if (context.resume.resolution !== true) {
return { sent: false };
}
await messageService.send({ recipient, body });
return { sent: true };
},
},
};context.interrupt() uses an internal throw to leave the host function. Do not
catch it inside the host function.
Next, start the run and handle the interrupted result:
const input = {
source: `
return await messages.send(
"ada@example.com",
"The report is ready."
);
`,
hostFunctions,
continuationContext: {
tenantId: 'tenant_123',
userId: 'user_456',
},
};
const result = await runner.run(input);
if (result.status === 'interrupted') {
await approvalStore.create({
id: 'approval_789',
continuation: result.continuation,
interruptions: result.interruptions,
tenantId: 'tenant_123',
userId: 'user_456',
});
}The host presents the interruption payload to an authorized actor. When a decision is available, load the same token and resume with resolutions for one or more pending interruptions. This example resolves all of them:
const approval = await approvalStore.get('approval_789');
const resumed = await runner.run({
...input,
continuation: approval.continuation,
resolutions: approval.interruptions.map(interruption => ({
interruptionId: interruption.id,
value: true,
})),
});
if (resumed.status === 'completed') {
console.log(resumed.value);
}The original source, asynchronous and synchronous host function names, module loader identity, audience, and continuation context must match when the run resumes. A mismatch rejects the continuation before the interrupted host function is invoked.
Host function implementations are not compared. Deployments must preserve compatible behavior for interrupted host functions while old continuations remain valid.
Several host functions can interrupt during the same invocation. The result contains the full batch, and the host can resolve any non-empty subset of it. Unresolved calls keep their IDs and payloads and remain pending without being invoked again. Resolved calls can advance independently and reach new host calls or interruptions. If the run remains interrupted, save the returned continuation and pending interruptions for the next resume. Apply subsequent resolutions to that updated continuation so completed work is preserved.
Each supplied resolution must identify a different pending interruption. Empty
batches, duplicate IDs, and unknown IDs are rejected. Partial resumption does
not cancel or detach outstanding calls; a program using Promise.race should
still drain those calls before returning.
The continuation ledger records asynchronous calls, synchronous calls, and module normalization and loading in their aggregate request order. Replay uses recorded synchronous and module outcomes without invoking those integrations again. Calls reached for the first time after the recorded frontier dispatch normally and are added to a later continuation if the run interrupts again.
A module loader needs a non-empty, stable identity to create or resume a
continuation. Change the identity whenever its resolution rules, module graph,
or configuration changes incompatibly.
Signing
Signed continuations are self-contained tokens protected with HMAC. The signature prevents modification, but the token is not encrypted. Source, host function arguments, results, errors, and interruption payloads may be visible to anyone who receives it.
Use createSignedContinuationCodec() when the application needs custom
expiration or signing-key rotation:
import { createRunner, createSignedContinuationCodec } from 'run';
const runner = createRunner({
continuationAudience: 'message-approval-v1',
continuationCodec: createSignedContinuationCodec({
secret: process.env.RUN_CONTINUATION_SECRET_CURRENT!,
verificationSecrets: [process.env.RUN_CONTINUATION_SECRET_PREVIOUS!],
maxAgeMs: 15 * 60 * 1000,
}),
});New tokens are signed with secret. Values in verificationSecrets are used
only to verify older tokens during a key rotation.
Signed tokens expire after one hour by default. They can be replayed until they expire, so the host must still prevent a decision from being submitted more times than the application permits.
Storage
Use createStoredContinuationCodec() when replay state should remain in
host-controlled storage or a continuation should be consumed at most once. The
token returned to the caller is a random storage key rather than the complete
signed state.
import {
createRunner,
createStoredContinuationCodec,
type ContinuationStorage,
} from 'run';
const storage: ContinuationStorage = {
async set(key, value, context) {
await continuationStore.set(key, value, {
deadlineMs: context?.deadlineMs,
signal: context?.abortSignal,
});
},
async acquire(key, claimId, context) {
return continuationStore.claim(key, {
claimId,
claimMs: 30_000,
deadlineMs: context?.deadlineMs,
signal: context?.abortSignal,
});
},
async consume(key, claimId) {
await continuationStore.deleteIfClaimedBy(key, claimId);
},
async release(key, claimId) {
await continuationStore.releaseIfClaimedBy(key, claimId);
},
};
const runner = createRunner({
continuationAudience: 'message-approval-v1',
continuationCodec: createStoredContinuationCodec({
storage,
maxAgeMs: 15 * 60 * 1000,
}),
});These methods implement a temporary claim. acquire() must atomically allow
only one caller to claim a continuation without deleting it. consume() and
release() must only modify a record when the supplied claim identifier
matches its current claim.
The codec manages this lifecycle automatically:
- It calls
acquire()before decoding. - It calls
release()when decoding is cancelled or replay validation fails. - It calls
consume()after replay validation succeeds, before the worker resumes execution.
Claims must expire automatically after a bounded interval. If the host process crashes before cleanup, expiry makes the continuation available for another attempt instead of leaving it permanently unavailable.
Storage implementations should honor the supplied abort signal and operation
deadline during set() and acquire(). Cleanup operations intentionally do not
receive the aborted context.
Scope
Use continuationAudience to separate applications or resume endpoints. Use
continuationContext to bind a logical run to serializable tenant, principal,
or policy information:
const continuationContext = {
tenantId,
userId,
policyVersion,
};
const result = await runner.run({
source,
hostFunctions,
continuationContext,
});Pass the same context when resuming. A mismatch is rejected before replayed results are returned.
Scope validation does not authorize the person submitting a resolution. The resume endpoint must authenticate the caller and verify that the caller may approve the requested action.
Treat every continuation token as a bearer capability. Store it behind access controls, transmit it over an authenticated channel, and do not write the raw token to logs.