Skip to content
HomeConsoleGet started

Reference for runstate-sdk on npm, the TypeScript SDK for Node.js 22 and later. Every public class, method, option and default.

npm install runstate-sdk

runstate-sdk is an ES module for Node.js 22 or later (MIT licensed). Everything below is exported from the package root:

import { Runstate, ConflictError, LeaseLostError, decimalToMinor } from 'runstate-sdk';

Signatures are shown as declarations. Optional fields show their default in a comment. All methods that talk to the API return promises and throw the typed errors listed at the end.

The client. Constructing it validates configuration and makes no network calls.

class Runstate {
constructor(options?: RunstateOptions);
readonly spaceId: string;
readonly scopes: ScopesAPI;
readonly mailboxes: MailboxesAdminAPI;
readonly pools: PoolsAdminAPI;
readonly quotas: AllowancesAdminAPI;
readonly workLimits: WorkLimitsAdminAPI;
readonly budgets: BudgetsAdminAPI;
readonly events: EventsAPI;
readonly diagnostics: DiagnosticsAPI;
scope(id: string): ScopeHandle; // local handle to an existing run, no request
withSpace(spaceId: string): Runstate; // same config and identity, another space
close(): void; // no-op; the client holds no connections
}
interface RunstateOptions {
apiKey?: string; // default: RUNSTATE_API_KEY (required)
spaceId?: string; // default: RUNSTATE_SPACE_ID (required)
baseURL?: string; // default: RUNSTATE_BASE_URL, else 'http://localhost:8080'
requestTimeoutMs?: number; // default 10_000, per attempt
retries?: number; // default 2, transport failures only
holder?: string; // default: random 'worker-xxxxxx'
fetchImpl?: typeof fetch; // default: global fetch
}

Throws ConfigError if apiKey or spaceId is missing. See Configuration & auth.

class ScopesAPI {
create(opts?: {
name?: string; // accepted but not sent to the API today
parentId?: string; // create as a child of this run
deadline?: string; // ISO 8601; new work is refused after it
childLimit?: number; // 1–10000, server default 1000
}): Promise<ScopeHandle>;
}

Each call creates a new run.

A run. Everything created through it is bound to the run’s id.

class ScopeHandle {
readonly id: string;
readonly timers: TimersAPI;
readonly barriers: BarriersAPI;
readonly groups: GroupsAPI;
status(): Promise<{ id: string; storedState: string; effective: string }>;
child(opts?: { deadline?: string; childLimit?: number }): Promise<ScopeHandle>;
cancel(): Promise<{ id: string; state: string; version: string }>;
complete(): Promise<{ id: string; state: string; version: string }>;
claim(key: string): ClaimRef;
mailbox(name: string): MailboxRef;
mailboxById(mailboxId: string): MailboxRef;
pool(name: string): PoolRef;
poolById(permitId: string): PoolRef;
quota(name: string): AllowanceRef;
quotaById(allowanceId: string): AllowanceRef;
budget(name: string): BudgetRef;
task(taskId: string): TaskTicket; // re-attach to a task by id, no request
}

storedState is ACTIVE, CANCELLED or COMPLETED; effective is ACTIVE or INACTIVE (accounts for ancestors and the deadline). cancel() and complete() on a run that is no longer active throw ConflictError (CONFLICT). Guide: Task groups and cancellation.

Resources are created per space with ensure, which creates the resource or returns the existing one if its settings match. Name lookups (run.mailbox(name) and friends) resolve lazily on first use and throw NotFoundError if the resource doesn’t exist.

class MailboxesAdminAPI {
ensure(name: string, opts?: {
mode?: 'WORK' | 'INBOX'; // server default 'WORK'
backlogLimit?: number; // server default 1000
}): Promise<{ id: string; name: string; mode: string }>;
}
class PoolsAdminAPI {
ensure(name: string, opts: { capacity: number }): Promise<{ id: string; name: string; unitsTotal: number }>;
}
class AllowancesAdminAPI { // rs.quotas
ensure(name: string, opts: { unitsPerWindow: number; windowSeconds: number }): Promise<AllowanceSummary>;
}
class WorkLimitsAdminAPI { // rs.workLimits
ensure(name: string, opts: { scopeId: string; maxOutstanding: number }): Promise<WorkLimitSummary>;
}
class BudgetsAdminAPI {
ensure(name: string, opts: {
currency: string; // three letters, e.g. 'USD'
scale: number; // decimal places, 0–9
limit: string; // major units, e.g. '200.00'
}): Promise<BudgetSummary>;
}
interface AllowanceSummary { id: string; name: string; unitsLimit: number; windowSeconds: number }
interface WorkLimitSummary { id: string; name: string; scopeId: string; maxOutstanding: number; outstanding?: number }
interface BudgetSummary { id: string; name: string; currency: string; scale: number; limitMinor: string }

A mismatch (a different mode, capacity, window, limit or scale) throws ConflictError (budgets: RunstateError with code CONFLICT).

Guide: Claims, shared tasks and takeover.

class ClaimRef {
readonly scopeId: string;
readonly key: string;
acquire(opts?: AcquireOptions): Promise<ClaimLease>;
tryAcquire(opts?: AcquireOptions): Promise<{ kind: 'acquired'; lease: ClaimLease } | { kind: 'busy' }>;
run<T>(fn: (lease: ClaimLease) => Promise<T>, opts?: AcquireOptions): Promise<T>;
}
interface AcquireOptions {
leaseSeconds?: number; // default 30; server allows 5–600
wait?: boolean; // default false
timeoutMs?: number; // default 30_000, bounds waiting
signal?: AbortSignal; // cancels an in-flight request
}
class ClaimLease {
readonly key: string;
readonly generation: string; // increases with each new owner of the key
expiresAt: Date; // updated on each renewal
readonly signal: AbortSignal; // aborted when the lease is lost
get isLost(): boolean;
token(): string; // fencing token; redacted in toJSON()
renew(leaseSeconds?: number): Promise<void>; // normally automatic
release(observation?: unknown): Promise<void>;
}
  • acquire() throws ConflictError (CLAIM_HELD) when the key is owned and wait is false. With wait: true it retries CLAIM_HELD every ~300 ms and CONCURRENCY_LIMITED every ~1 s until timeoutMs, then throws WaitTimeoutError.
  • tryAcquire() returns { kind: 'busy' } instead of throwing CLAIM_HELD. It passes wait through, so leave wait unset.
  • Renewal starts as soon as the lease exists, runs about every leaseSeconds / 3 (±10%), one request at a time, and stops for good on the first failure, aborting signal.
  • run() releases the lease in finally. It does not stop fn when the lease is lost; check lease.isLost or pass lease.signal to your own work. (Python’s run() cancels the handler instead.)
  • release() is idempotent and quietly does nothing if the lease was already lost.

Guide: Claims, shared tasks and takeover.

class MailboxRef {
readonly scopeId: string;
send(data: unknown, opts?: { workKey?: string; deadline?: string }): Promise<{ id: string; replay: boolean }>;
submit(input: unknown, opts: {
key: string; // task key, unique per run
deadline?: string; // ISO 8601; the task EXPIRES if unfinished
subscriberId?: string;
requirements?: TaskRequirements; // for admission
workLimit?: string; // work limit name, for admission
}): Promise<TaskTicket>;
receive<T = unknown>(): Promise<ManualDelivery<T> | null>; // 30 s lease, not renewed
admit<T = unknown>(opts?: { leaseSeconds?: number /* default 30 */ }): Promise<AdmittedWork<T> | null>;
consume<T = unknown>(
handler: (data: T, delivery: ManualDelivery<T>) => Promise<void>,
opts?: ConsumeOptions,
): Promise<void>;
}
interface ManualDelivery<T = unknown> {
data: T;
id: string;
attempt: number;
scopeId: string;
complete(result?: ResultPublication): Promise<void>;
retry(): Promise<void>; // requeue with backoff
reject(): Promise<void>; // dead-letter, task FAILED
renew(leaseSeconds?: number): Promise<void>;
}
interface ResultPublication {
payload?: unknown; // recorded as the task outcome
mailboxId?: string; // with scopeId: also publish the result to that queue
scopeId?: string;
workKey?: string;
}
interface ConsumeOptions {
concurrency?: number; // default 1
leaseSeconds?: number; // default 30, renewed while the handler runs
pollMs?: number; // default 250, sleep when idle or at capacity
signal?: AbortSignal; // stop intake, drain, requeue unfinished
shutdownTimeoutMs?: number; // default 30_000
onError?: (err: unknown, delivery: ManualDelivery) => void;
}
interface AdmittedWork<T = unknown> {
task: TaskTicket;
delivery: ManualDelivery<T>;
resources: { grants: string[]; consumptions: string[] };
attempt: number;
}
interface TaskRequirements {
pools?: Array<{ name: string; units: number }>; // up to 4
quotas?: Array<{ name: string; units: number }>; // up to 4
}
  • complete, retry and reject settle a delivery once; later calls on the same delivery do nothing.
  • consume() runs until signal aborts or the run is cancelled. Handler success completes the delivery (without a payload unless the handler already completed it); a thrown error calls onError and retries it. AuthenticationError, ConfigError and NotFoundError from receiving are rethrown; ScopeCancelledError ends the loop; other errors are retried after pollMs.
  • admit() returns null when nothing can be admitted. It doesn’t renew anything. Don’t mix it with receive() or consume() on the same queue. See Admission.
class TaskTicket {
readonly id: string;
status(): Promise<TaskView>;
result(opts?: TaskResultOptions): Promise<{ state: string; outcome: unknown; reason: string | null }>;
awaitAdmission(opts?: TaskResultOptions): Promise<TaskView>; // until RUNNING or terminal
cancel(reason?: string): Promise<void>;
join(subscriberId: string, scopeId: string): Promise<{ state: string }>;
detach(subscriberId: string): Promise<{ detached: boolean }>;
}
interface TaskResultOptions {
timeoutMs?: number; // default 30_000
pollMs?: number; // default 250, jittered
signal?: AbortSignal;
}
interface TaskView {
id: string;
scopeId: string;
taskKey: string;
state: string; // PENDING | RUNNING | SUCCEEDED | FAILED | CANCELLED | EXPIRED
mailboxId: string;
deliveryMessageId: string | null;
attempt: number;
outcome: unknown;
outcomeReason: string | null;
terminalAt: string | null;
deadline: string | null;
tombstoned: boolean; // outcome cleared after the retention window
createdAt: string;
subscribers: Array<{ subscriberId: string; scopeId: string; detached: boolean; joinedAt: string }>;
requirements?: Array<{ kind: string; name: string; units: number }>;
admittedAt?: string | null;
admissions?: Array<{ attempt: number; holder: string; admittedAt: string; resources: string[] }>;
}

result() and awaitAdmission() are client-side polls: on timeout they throw WaitTimeoutError and the task carries on. Cancelling an already finished task throws ConflictError.

Guide: Task groups and cancellation.

class GroupsAPI { // run.groups
create(opts: {
mailbox: string; // queue name; must exist
condition: 'FIRST_ACCEPTED' | 'N_ACCEPTED' | 'ALL_TERMINAL';
threshold?: number; // for N_ACCEPTED
deadline?: string; // ISO 8601
parentScopeId?: string; // make the group's run a child of this run
}): Promise<GroupHandle>;
byId(groupId: string): GroupHandle;
}
class GroupHandle {
readonly id: string;
status(): Promise<GroupView>;
submit(input: unknown, opts: { key: string; deadline?: string }): Promise<TaskTicket>;
seal(): Promise<{ state: string }>; // required before ALL_TERMINAL can decide
accept(taskId: string): Promise<VerdictResult>; // member must be SUCCEEDED
reject(taskId: string): Promise<VerdictResult>; // member must be finished
joinTask(taskId: string): Promise<{ state: string }>;
wait(opts?: { timeoutMs?: number; pollMs?: number; signal?: AbortSignal }): Promise<{
outcome: string | null; // 'SUCCESS' | 'FAILURE'
outcomeReason: string | null;
}>;
}
interface GroupView {
id: string;
scopeId: string; // the group's own run; workers attach here
condition: string;
threshold: number | null;
state: string; // OPEN | SEALED | FINALIZED
outcome: string | null;
outcomeReason: string | null;
decidedAt: string | null;
deadline: string | null;
members: Array<{ taskId: string; taskKey: string; taskState: string; verdict: string | null; late: boolean; outcomePresent: boolean }>;
}
interface VerdictResult {
verdict: string;
late: boolean; // recorded after the group decided
groupState: string;
outcome: string | null;
outcomeReason: string | null;
}

wait() defaults: timeoutMs 30 000, pollMs 250.

Guide: Quotas and concurrency pools.

class PoolRef {
readonly scopeId: string;
status(): Promise<{ id: string; name: string; unitsTotal: number } | undefined>;
acquire(opts?: PoolAcquireOptions): Promise<PoolLease>;
tryAcquire(opts?: PoolAcquireOptions): Promise<{ kind: 'acquired'; lease: PoolLease } | { kind: 'capacity-unavailable' }>;
run<T>(fn: (lease: PoolLease) => Promise<T>, opts?: PoolAcquireOptions): Promise<T>;
}
interface PoolAcquireOptions {
units?: number; // default 1
leaseSeconds?: number; // default 30
wait?: boolean; // default true
timeoutMs?: number; // default 60_000
signal?: AbortSignal;
}
class PoolLease {
readonly grantId: string;
expiresAt: Date;
readonly signal: AbortSignal;
get isLost(): boolean;
token(): string;
renew(leaseSeconds?: number): Promise<void>;
release(observation?: unknown): Promise<void>;
}

Waiting retries CAPACITY_UNAVAILABLE every ~300 ms and CONCURRENCY_LIMITED every ~1 s (client-side polling, not a server queue). Without waiting, a full pool throws ConflictError (CAPACITY_UNAVAILABLE). Renewal and lease loss work as for claims, and run() likewise doesn’t stop fn on lease loss.

Guide: Quotas and concurrency pools.

class AllowanceRef { // run.quota(name)
readonly scopeId: string;
status(): Promise<AllowanceStatus | undefined>;
tryTake(opts?: { units?: number /* default 1 */ }): Promise<{ kind: 'consumed'; consumption: Consumption } | { kind: 'exhausted' }>;
take(opts?: {
units?: number; // default 1
wait?: boolean; // default true
timeoutMs?: number; // default 30_000
signal?: AbortSignal;
}): Promise<Consumption>;
cooldown(seconds: number, reason?: string): Promise<{ cooldownUntil: string }>; // 1–3600 s
}
interface Consumption { consumptionId: string; units: number; windowStart: string; remaining: number }
interface AllowanceStatus {
id: string;
name: string;
unitsLimit: number;
windowSeconds: number;
windowStart: string;
consumed: number;
remaining: number;
cooldownUntil: string | null;
}
  • take() tries to consume; if the window is exhausted it registers one durable FIFO waiter on the server (time-to-live between 5 s and 1 h, from the remaining timeout) and polls it every ~250 ms. On timeout it cancels the waiter (best effort) and throws WaitTimeoutError.
  • take({ wait: false }) on an exhausted window throws WaitTimeoutError. tryTake() returns { kind: 'exhausted' } for both ALLOWANCE_EXHAUSTED and ALLOWANCE_COOLDOWN.
  • Consumed units are not refundable.

Guide: Spend budgets.

class BudgetRef { // run.budget(name)
readonly scopeId: string;
status(): Promise<BudgetDetailView>;
reserve(opts: { amount: string; settleBy?: string }): Promise<BudgetReservation>;
}
class BudgetReservation {
readonly id: string;
readonly reservedMinor: string; // as of reserve()
readonly state: string; // as of reserve()
get reserved(): string; // reservedMinor in major units
settle(opts: { amount: string; usageKey: string }): Promise<{ reservedMinor: string; state: string; replay: boolean }>;
void(opts: { usageKey: string }): Promise<{ state: string; replay: boolean }>;
}
interface BudgetDetailView {
id: string;
name: string;
currency: string;
scale: number;
limitMinor: string;
reservedMinor: string;
settledMinor: string;
availableMinor: string;
reservations: Array<{
id: string;
scopeId: string;
holder: string;
amountMinor: string;
reservedMinor: string;
state: string; // ACTIVE | SETTLED | VOID | STALE
settleBy: string | null;
createdAt: string;
}>;
}
function decimalToMinor(amount: string, scale: number): string; // '1.25', 2 -> '125'
function minorToDecimal(minor: string, scale: number): string; // '125', 2 -> '1.25'

reserve() throws ConflictError (INSUFFICIENT_BUDGET) when the budget can’t cover the amount. Amounts must match ^\d+(\.\d+)?$ with no more decimal places than the budget’s scale; otherwise decimalToMinor throws RunstateError with code VALIDATION_FAILED before any request.

class BarriersAPI { // run.barriers
create(opts: { target: number }): Promise<BarrierHandle>;
get(id: string): BarrierHandle;
}
class BarrierHandle {
readonly id: string;
arrive(opts: { key: string }): Promise<{ state: string; arrivals: number }>;
status(): Promise<{ id: string; state: string; epoch: string; arrivals: number; target: number }>;
wait(opts?: { timeoutMs?: number /* 60_000 */; pollMs?: number /* 250 */; signal?: AbortSignal }): Promise<{
id: string; state: string; epoch: string; arrivals: number; target: number;
}>;
}

wait() resolves when the barrier is RELEASED or its epoch has moved on since the call started, throws ConflictError (BARRIER_CANCELLED) if it’s cancelled, and throws WaitTimeoutError on timeout or when signal aborts.

class TimersAPI { // run.timers
create(opts?: { afterSeconds?: number; at?: string }): Promise<TimerHandle>;
get(id: string): TimerHandle; // fireAt is unknown (new Date(0)) on a re-attached handle
}
class TimerHandle {
readonly id: string;
readonly scopeId: string;
readonly fireAt: Date;
wait(opts?: { timeoutMs?: number /* 30_000 */; pollMs?: number /* 250 */; signal?: AbortSignal }): Promise<{ firedAt: Date }>;
cancel(): Promise<{ state: string }>;
}

wait() polls for the fire and acknowledges it; use one waiter per timer.

Guide: Events and monitoring.

class EventsAPI { // rs.events
list(opts?: { cursor?: string; limit?: number }): Promise<EventPage>;
watch(opts?: WatchOptions): AsyncGenerator<EventItem & { cursor: string }>;
}
interface WatchOptions {
cursor?: string;
types?: string[];
aggregate?: { type: string; id: string };
scopeId?: string; // the run and its descendants
limit?: number;
waitSeconds?: number; // 0–30
signal?: AbortSignal; // ends the stream
}
interface EventItem {
eventId: string;
type: string;
aggregateType: string;
aggregateId: string;
aggregateVersion: string;
payload: unknown;
createdAt: string;
}
interface EventPage { events: EventItem[]; nextCursor: string | null }
class DiagnosticsAPI { // rs.diagnostics
run(scopeId: string): Promise<RunView>;
waits(): Promise<WaitsReport>;
workers(scopeId: string): Promise<Array<{ holder: string; keys: string[]; earliestLeaseExpiry: string | null }>>;
}
interface RunView {
rootScopeId: string;
scopes: Array<{ id: string; parentId: string | null; state: string }>;
workers: Array<{ holder: string; keys: string[]; earliestLeaseExpiry: string | null }>;
waiting: Array<{ scopeId: string; permit: string; units: number; blockedBy: string[] }>;
expiredRecent: Array<{ kind: 'claim' | 'grant'; key: string; holder: string; at: string }>;
messages: Array<{ mailbox: string; byState: Record<string, number>; deadRecent: number }>;
deliveriesFailed: Array<{ statusOrError: string | null; at: string }>;
timeline: Array<{ eventId: string; type: string; aggregate: string; at: string }>;
}
interface WaitsReport {
waiters: Array<{ waiterId: string; permitId: string; scopeId: string; holder: string; units: number }>;
grants: Array<{ grantId: string; permitId: string; scopeId: string; holder: string; units: number; state: string }>;
claims: Array<{ key: string; holder: string; scopeId: string; state: string }>;
}

Every error thrown by the SDK is a RunstateError subclass:

class RunstateError extends Error {
readonly code: string; // e.g. 'CLAIM_HELD'
readonly status: number; // HTTP status, 0 for client-side errors
readonly requestId?: string;
readonly retryAfterMs?: number; // from a Retry-After header, when present
}
class ConfigError extends RunstateError {} // missing apiKey/spaceId; never from the server
class AuthenticationError extends RunstateError {} // UNAUTHENTICATED, FORBIDDEN, ENTITLEMENT_EXCEEDED, SPACE_SUSPENDED
class NotFoundError extends RunstateError {}
class ConflictError extends RunstateError {} // CONFLICT, CLAIM_HELD, CAPACITY_UNAVAILABLE, INSUFFICIENT_BUDGET, ...
class ScopeCancelledError extends RunstateError {}
class LeaseLostError extends RunstateError {} // STALE_CLAIM, LEASE_RENEWALS_EXHAUSTED
class RateLimitError extends RunstateError {} // RATE_LIMITED, ALLOWANCE_*, CONCURRENCY_LIMITED, ...
class UnavailableError extends RunstateError {} // transport failure after retries
class WaitTimeoutError extends RunstateError {} // a client-side wait ran out; never from the server
class CursorExpiredError extends RunstateError {} // reserved; not emitted today
function mapError(init: { code: string; status: number; message: string; requestId?: string; retryAfterMs?: number }): RunstateError;
import { ConflictError, RateLimitError, RunstateError } from 'runstate-sdk';
try {
await run.claim('company:acme').acquire();
} catch (err) {
if (err instanceof ConflictError && err.code === 'CLAIM_HELD') {
// someone else owns it
} else if (err instanceof RateLimitError) {
console.log(`rate limited: ${err.code}, retry after ${err.retryAfterMs ?? '?'} ms`);
} else if (err instanceof RunstateError) {
console.error(err.code, err.status, err.requestId);
}
throw err;
}

The full code table, including which codes the SDK waits through, is on Errors & retries.

  • createRunstateClient and KernelError: the low-level client for infrastructure authors.
  • resolveConfig, defaultHolder, makeSession, makeIdentity and startRenewal: helpers the client uses internally. They are exported, but not a stable surface to build on.