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; // label, 1–256 chars; returned by status(), not unique 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. name is a label for people: it’s returned by status() and GET /scopes and shown in diagnostics, but runs are never looked up by name.
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; name: string | null; parentId: string | null; storedState: string; effective: string; deadline: string | null; version: string; createdAt: string; }>; child(opts?: { name?: string; 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. Creating needs a key with resource_config; with a key that lacks it, ensure returns a matching existing resource and throws AuthenticationError (FORBIDDEN) only when there is nothing to verify. 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>(opts?: { waitMs?: number /* long poll, default 0 */ }): Promise<ManualDelivery<T> | null>; // 30 s lease, not renewed admit<T = unknown>(opts?: { leaseSeconds?: number /* default 30 */; waitMs?: number /* long poll, default 0 */ }): 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 waitMs?: number; // default 20_000, server-side long poll while the queue is empty; 0 disables pollMs?: number; // default 250, sleep when at capacity, after an error, or when an empty receive wasn't held 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[]; reservations: string[] }; attempt: number;}
interface TaskRequirements { pools?: Array<{ name: string; units: number }>; // up to 4 quotas?: Array<{ name: string; units: number }>; // up to 4 budgets?: Array<{ name: string; reserveMinor: string }>; // up to 2; minor units, e.g. "250" = 2.50 at scale 2}complete,retryandrejectsettle a delivery once; later calls on the same delivery do nothing.receive({ waitMs })andadmit({ waitMs })long-poll: the server holds the request until work is available orwaitMspasses (at most the server’s limit, 30 s on the hosted API), then returnsnull. A message sent during the wait is delivered within about a second.consume()long-polls withwaitMswhile the queue is empty, so an idle worker makes about three requests a minute. Abortingsignalends the current wait immediately.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 expectedMembers?: number; // 1-100; close automatically once this many have joined 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>; close(): Promise<{ state: string; outcome: string | null; outcomeReason: string | null }>; // no more members; evaluates now seal(): Promise<{ state: string; outcome: string | null; outcomeReason: string | null }>; // deprecated: close(), but ConflictError once finalized 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; expectedMembers: number | null; state: string; // OPEN | SEALED (closed) | 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; name: string | null; 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.