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.

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 a resolution for every interruption:

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, host function names, 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 must resolve every interruption in that batch together. Replay may later reach another batch of interruptions.

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:

  1. It calls acquire() before decoding.
  2. It calls release() when decoding is cancelled or replay validation fails.
  3. 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.