# TypeScript SDK

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

Source: https://docs.getrunstate.com/sdk/typescript/

```bash
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:

```ts
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](#errors) listed at the end.

## Runstate

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

```ts
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](https://docs.getrunstate.com/configuration/).

## Runs

### ScopesAPI

```ts
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

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

```ts
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](https://docs.getrunstate.com/guides/completion-and-cancellation/#cancel-a-run).

## 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.

```ts
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

Guide: [Claims, shared tasks and takeover](https://docs.getrunstate.com/guides/work-once/#own-a-key-with-a-claim).

```ts
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.

## Work queues

Guide: [Claims, shared tasks and takeover](https://docs.getrunstate.com/guides/work-once/#workers-and-crash-takeover).

```ts
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](https://docs.getrunstate.com/guides/share-capacity/#admission-start-only-when-everything-is-available).

## Tasks

```ts
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

Guide: [Task groups and cancellation](https://docs.getrunstate.com/guides/completion-and-cancellation/#task-groups).

```ts
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

Guide: [Quotas and concurrency pools](https://docs.getrunstate.com/guides/share-capacity/#concurrency-pools).

```ts
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

Guide: [Quotas and concurrency pools](https://docs.getrunstate.com/guides/share-capacity/#shared-quotas).

```ts
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.

## Budgets

Guide: [Spend budgets](https://docs.getrunstate.com/guides/budgets/).

```ts
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

```ts
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

```ts
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

Guide: [Events and monitoring](https://docs.getrunstate.com/guides/observability/#events).

```ts
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

```ts
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

Every error thrown by the SDK is a `RunstateError` subclass:

```ts
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;
```

```ts
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](https://docs.getrunstate.com/errors/).

## Also exported

- `createRunstateClient` and `KernelError`: the [low-level client](https://docs.getrunstate.com/sdk/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.
