TypeScript SDK
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-sdkrunstate-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.
Runstate
Section titled “Runstate”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.
ScopesAPI
Section titled “ScopesAPI”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.
ScopeHandle
Section titled “ScopeHandle”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.
Space-level resources
Section titled “Space-level resources”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).
Claims
Section titled “Claims”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()throwsConflictError(CLAIM_HELD) when the key is owned andwaitis false. Withwait: trueit retriesCLAIM_HELDevery ~300 ms andCONCURRENCY_LIMITEDevery ~1 s untiltimeoutMs, then throwsWaitTimeoutError.tryAcquire()returns{ kind: 'busy' }instead of throwingCLAIM_HELD. It passeswaitthrough, so leavewaitunset.- 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, abortingsignal. run()releases the lease infinally. It does not stopfnwhen the lease is lost; checklease.isLostor passlease.signalto your own work. (Python’srun()cancels the handler instead.)release()is idempotent and quietly does nothing if the lease was already lost.
Work queues
Section titled “Work queues”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,retryandrejectsettle a delivery once; later calls on the same delivery do nothing.consume()runs untilsignalaborts or the run is cancelled. Handler success completes the delivery (without a payload unless the handler already completed it); a thrown error callsonErrorand retries it.AuthenticationError,ConfigErrorandNotFoundErrorfrom receiving are rethrown;ScopeCancelledErrorends the loop; other errors are retried afterpollMs.admit()returnsnullwhen nothing can be admitted. It doesn’t renew anything. Don’t mix it withreceive()orconsume()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.
Task groups
Section titled “Task groups”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.
Concurrency pools
Section titled “Concurrency pools”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.
Shared quotas
Section titled “Shared quotas”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 throwsWaitTimeoutError.take({ wait: false })on an exhausted window throwsWaitTimeoutError.tryTake()returns{ kind: 'exhausted' }for bothALLOWANCE_EXHAUSTEDandALLOWANCE_COOLDOWN.- Consumed units are not refundable.
Budgets
Section titled “Budgets”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.
Barriers
Section titled “Barriers”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.
Timers
Section titled “Timers”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.
Events
Section titled “Events”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 }Diagnostics
Section titled “Diagnostics”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 }>;}Errors
Section titled “Errors”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 serverclass AuthenticationError extends RunstateError {} // UNAUTHENTICATED, FORBIDDEN, ENTITLEMENT_EXCEEDED, SPACE_SUSPENDEDclass 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_EXHAUSTEDclass RateLimitError extends RunstateError {} // RATE_LIMITED, ALLOWANCE_*, CONCURRENCY_LIMITED, ...class UnavailableError extends RunstateError {} // transport failure after retriesclass WaitTimeoutError extends RunstateError {} // a client-side wait ran out; never from the serverclass 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.
Also exported
Section titled “Also exported”createRunstateClientandKernelError: the low-level client for infrastructure authors.resolveConfig,defaultHolder,makeSession,makeIdentityandstartRenewal: helpers the client uses internally. They are exported, but not a stable surface to build on.