Skip to content
HomeConsoleGet started

Reference for runstate-sdk 0.3 on npm, the TypeScript SDK for Node.js 22 and later. Every function, option, default and error.

npm install runstate-sdk

runstate-sdk is an ES module for Node.js 22 or later (MIT licensed). You wrap a function once where you define it and call it exactly as before. runstate does the coordination inside the call.

import runstate from 'runstate-sdk';
const research = runstate.once(researchCompany); // once per run for the same arguments
const search = runstate.limit('search-api', searchWeb, { max: 60, per: 'minute' });
const browse = runstate.slots('browser', openPage, { max: 5 });
const ask = runstate.budget('model-usd', callModel, { reserve: 0.4, cost: (r) => r.usd, limit: 50 });
await research('acme'); // looks like a normal call
await runstate.stop(); // every worker stops taking new work

The default export is a ready-made client. The package also exports the Runstate class, isRunstateError and the error classes, plus types for the options and results (OnceOptions, QueueOptions, Queue, Job, RunContext, RunEvent, RunStatus and others). Every function that talks to the API returns a promise, so callers await wrapped functions even when the original function was synchronous.

The HTTP API is still available if you work in another language. Everything on this page maps onto it.

Variable Required Default Meaning
RUNSTATE_API_KEY yes none The API key from the console.
RUNSTATE_SPACE_ID yes none The space the key works in.
RUNSTATE_BASE_URL no https://api.getrunstate.com API address. Use http://localhost:8080 for a local kernel.
RUNSTATE_RUN no default The run name every process joins.

The default client reads these on first use. Importing the package makes no network calls. Missing configuration throws ConfigError on the first call.

import runstate, { Runstate } from 'runstate-sdk';
runstate.configure({ apiKey, spaceId, baseUrl, run: 'nightly' }); // change the default client
const rs = new Runstate({ apiKey, spaceId, run: 'nightly' }); // or make your own
Option Default Notes
apiKey RUNSTATE_API_KEY Required.
spaceId RUNSTATE_SPACE_ID Required.
baseUrl RUNSTATE_BASE_URL, else https://api.getrunstate.com
run RUNSTATE_RUN, else default The run this client joins.
worker <hostname>-<pid> A label for this process, shown in the console.
timeout '30s' Per request.
retries 3 Transport retries.

A Runstate instance has exactly the same methods as the default export. Everything below written as runstate.x works on an instance too.

  • Durations are a number of seconds or a string: '500ms', '30s', '10m', '2h', '1d'.
  • per (for limit) also accepts 'second', 'minute', 'hour' or 'day'.
  • Amounts are numbers or decimal strings in major units (0.40, '12.5'). The SDK converts them to exact integer minor units using the budget’s scale. Numbers are rounded half up to the scale, so float noise like 0.1 + 0.2 becomes 0.30. A string with more decimal places than the scale is a ConfigError.

Every call belongs to a run. By default all processes join the run named by RUNSTATE_RUN (default). Joining is get-or-create: the first call creates the run, and every other process with the same name joins it.

runstate.run(name: string, opts?: { deadline?: number | string }): Runstate;
runstate.child(name?: string, opts?: { deadline?: number | string }): Runstate;
runstate.stop(): Promise<void>;
runstate.finish(): Promise<void>;
runstate.runId(): Promise<string>;
const nightly = runstate.run('nightly-2026-09-17', { deadline: '2h' }); // a client bound to that run
await nightly.once(fetchPrices)('eur');
const child = runstate.child('retry-batch', { deadline: '10m' }); // a child of the current run
await runstate.stop(); // stop the current run: nothing new starts anywhere
await runstate.finish(); // mark it finished
  • run(name) returns a client bound to the named root run. The run is created on the first call that needs it.
  • After a run is stopped or finished, or its deadline passes, the next call with the same name starts a fresh run. That is how once results reset: they belong to one run.
  • child(name?) returns a client bound to a new child run of the current one. Stopping a run stops its children.
  • stop() and finish() are idempotent.
  • runId() returns the id of the client’s run, joining or creating the run if needed.
  • A client that has seen its run end keeps throwing StoppedError, so a loop in a stopped worker can’t quietly start the next run. A new process, or a fresh runstate.run(name) client, joins the next run.
  • When a run stops, every wrapped call in flight sees context().signal abort, serve() stops taking work and drains, and new calls throw StoppedError.
runstate.context(): RunContext | undefined;
interface RunContext {
run: { id: string; name: string };
signal: AbortSignal; // aborts when the run stops or the hold behind this call is lost
attempt: number; // 1 for the first try
key: string | null; // the work key for once calls and queue jobs, otherwise null
}

Inside a wrapped function or queue handler, context() tells you which run you are in, which attempt this is and the key of the work. It returns undefined outside a wrapped call.

signal aborts when the run stops, its deadline passes, or this process loses its hold on the work. Long work should check it:

async function researchCompany(name: string) {
const { signal } = runstate.context()!;
return fetchReport(name, { signal });
}
const research = runstate.once(researchCompany);

When wrappers are nested, context() describes the innermost wrapper that holds something (once, slots or a queue job). Otherwise it describes the run.

runstate.once<A extends unknown[], R>(
fn: (...args: A) => R | Promise<R>,
opts?: {
name?: string; // default: fn.name
key?: (...args: A) => string; // default: hash of the arguments
attempts?: number; // default 3
lease?: number | string; // default '30s'
},
): (...args: A) => Promise<R>;
const research = runstate.once(researchCompany, { attempts: 3, lease: '30s' });
const summary = await research('acme');
  • The first caller with given arguments runs the function. Callers with the same arguments, in any process, wait and get the same result.
  • If the function throws, the next waiting caller tries again. After attempts failures every caller gets OnceFailedError with the last error message.
  • If the process dies, its lease lapses and another caller takes over. The lease is renewed automatically while the function runs.
  • The result must be JSON-serializable and at most 64 KB (ResultTooLargeError). Store big outputs elsewhere and return a reference.
  • Results belong to the run. A fresh run runs the function again.

The key is <name>:<hash>, where hash is the first 32 hex characters of the SHA-256 of the canonical JSON of the argument list:

  • JSON with object keys sorted, no whitespace, UTF-8, arrays kept in order.
  • Integers print as integers; other numbers use the shortest round-trip form (1.5, not 1.50). NaN and Infinity are a ConfigError.
  • Anything else (functions, class instances, dates) is a ConfigError. Pass key instead.
  • An anonymous function without name is a ConfigError. Name the function, or pass { name }:
const research = runstate.once(async (company: string) => fetchReport(company), { name: 'research' });

The Python SDK produces identical keys, so a TypeScript process and a Python process calling the same once with the same arguments share one result.

runstate.limit<A extends unknown[], R>(
name: string,
fn: (...args: A) => R | Promise<R>,
opts?: {
max?: number; // with per: declare the limit
per?: number | string; // window: seconds, a duration, or 'second' | 'minute' | 'hour' | 'day'
units?: number; // default 1
timeout?: number | string; // default '60s'
},
): (...args: A) => Promise<R>;
runstate.take(name: string, opts?: { units?: number; timeout?: number | string }): Promise<void>;
const search = runstate.limit('search-api', searchWeb, { max: 60, per: 'minute' });
await search('fusion'); // waits for a unit, then calls searchWeb
await runstate.take('search-api'); // just take a unit, no function
  • Every worker draws from the same limit. When the window is used up, the call waits in a fair queue on the server until the next window.
  • max and per declare the limit. If it doesn’t exist, it is created (the key needs the resource_config permission). If it exists with different sizes, the call throws ConflictError. Omit both to use a limit created in the console.
  • If no unit arrives within timeout, the call throws TimeoutError.
  • Units are spent even if the function then fails.
runstate.slots<A extends unknown[], R>(
name: string,
fn: (...args: A) => R | Promise<R>,
opts?: {
max?: number; // declare the pool of slots
units?: number; // default 1
timeout?: number | string; // default '60s'
lease?: number | string; // default '30s'
},
): (...args: A) => Promise<R>;
const browse = runstate.slots('browser', openPage, { max: 5 });
const html = await browse('https://example.com/pricing');
  • Waits for a free slot, runs the function, and always gives the slot back, also on error.
  • The slot is kept alive automatically while the function runs. If the process dies, the slot returns after its lease lapses.
  • max declares the slots the same way as limit: created if missing, ConflictError if the size differs.
  • If no slot frees up within timeout, the call throws TimeoutError.
runstate.budget<A extends unknown[], R>(
name: string,
fn: (...args: A) => R | Promise<R>,
opts: {
reserve: number | string; // held before the call
cost?: (result: R) => number | string; // the real cost, read from the result; default: the reserve
limit?: number | string; // declare the budget
currency?: string; // default 'USD'
scale?: number; // decimal places, default 2
},
): (...args: A) => Promise<R>;
const ask = runstate.budget('model-usd', callModel, {
reserve: 0.4,
cost: (result) => result.usd,
limit: 50,
});
  • Before the call, reserve is held. If the budget can’t cover it, the function is not called and BudgetExceededError is thrown.
  • After the call, cost(result) is charged and the rest of the reservation is released. If the function throws, the whole reservation is released.
  • If the real cost is higher than the reservation, the extra is charged from what is left. If the budget can’t cover it, what it can is charged, the result is still returned, and the overrun is reported in the run’s events as budget.overrun. Pick reserve as your worst case to avoid this.
  • cost may return a number or a decimal string. Returning 0 releases everything. Without cost, the full reserve is charged when the call succeeds.
  • limit declares the budget: created if missing, ConflictError if it differs.

A queue hands work to worker processes. Define it once in a module that both producers and workers import. The handler takes one argument, the input, and returns the result. Both must be JSON-serializable and at most 64 KB.

runstate.queue<I, R>(name: string, handler: (input: I) => R | Promise<R>, opts?: QueueOptions<I, R>): Queue<I, R>;
runstate.queue<I, R>(name: string, opts?: QueueOptions<I, R>): Queue<I, R>; // producer-only
interface QueueOptions<I, R> {
slots?: string | { name: string; units?: number }; // units default 1
limit?: string | { name: string; units?: number };
budget?: { name: string; reserve: number | string; cost?: (result: R) => number | string };
attempts?: number; // default 5
lease?: number | string; // default '30s'
concurrency?: number; // handlers at once in this process when serving, default 1
key?: (input: I) => string; // default: hash of the input
}
interface Queue<I, R> {
(input: I): Promise<R>; // submit and wait for the result
readonly queueName: string;
send(input: I): Promise<Job<R>>; // submit only
gather(
inputs: readonly I[],
opts?: { need?: number | 'all'; accept?: (result: R, input: I) => boolean | Promise<boolean>; timeout?: number | string },
): Promise<R[]>;
}
interface Job<R> {
readonly id: string;
result(opts?: { timeout?: number | string }): Promise<R>;
}
runstate.serve(opts?: { concurrency?: number; signal?: AbortSignal; queues?: Queue<any, any>[] }): Promise<void>;
// review.ts, imported by producers and workers
export const review = runstate.queue('review', async (doc: Doc) => scoreDoc(doc), {
slots: 'browser',
limit: 'model-rpm',
budget: { name: 'model-usd', reserve: 0.4, cost: (r) => r.usd }, // cost defaults to the reserve
attempts: 5,
concurrency: 4,
});
// producer
const score = await review(doc); // submit and wait for the result
const job = await review.send(doc); // submit only
console.log(job.id);
const later = await job.result({ timeout: '5m' });
// worker process
await runstate.serve(); // runs every queue defined in this process
  • Submitting the same input twice in a run returns the same job. The key is the input hash; pass key to choose it yourself.
  • A worker takes a job together with everything the queue declared (slots, limit units, budget reservation) in one step, so a job never starts half-ready.
  • If the handler throws, the job is retried with backoff until attempts is reached. Then callers get TaskFailedError with the last error message.
  • If the worker dies, the job goes back to the queue after its lease lapses.
  • review.queueName is the queue’s name.
  • slots and limit take a name or { name, units }. budget takes { name, reserve, cost? }. Without cost, the job is charged its full reserve when it succeeds and nothing when it fails.
  • The limit, slots and budget a queue names must already exist. Declare them with setup, with limit, slots or budget elsewhere, or in the console.
  • job.result() waits without a limit by default. With timeout, it throws TimeoutError when the time passes. The job keeps going on the server.
  • If a worker loses its hold on a job because another worker took it over, its result is dropped quietly. The caller gets the result of the worker that finished the job.

Call queue(name) or queue(name, opts) without a handler when the workers run in another process or language. You can call it, send to it and gather on it. serve() skips it.

const review = runstate.queue<Doc, Score>('review', { slots: 'browser' });
const score = await review(doc);

The queue remembers the slots, limit units and budget its workers declared, so a producer-only queue doesn’t repeat them. Jobs sent through it need the same resources as jobs sent by the workers. When workers are redeployed with different resources, the latest definition wins.

A producer-only queue uses the queue a worker has declared. Until a worker that defines the queue has started, calls throw a RunstateError with code NOT_FOUND and the message queue review not found. Start the workers first, or pass the same attempts the workers use so the producer declares the queue itself (the key then needs the resource_config permission if the queue doesn’t exist yet).

serve() runs every queue with a handler defined through this client, or through any client made from it with run() or child(). Pass queues: [review] to run only some. It takes jobs from the run and from every run under it, including child runs and gathers.

It runs until the run stops, signal aborts, or the process gets SIGTERM or SIGINT. Then it stops taking work and waits up to 30 seconds for running handlers before it returns. concurrency overrides each queue’s own setting.

await runstate.serve({ queues: [review], concurrency: 8 });
const accepted = await review.gather(docs, {
need: 5, // 'all' (default), or a number
accept: (result, doc) => result.score > 0.8,
timeout: '10m',
});
  • Submits every input as one group, at most 100 inputs per call. need is 'all' or a whole number from 1 to the number of inputs. accept runs in the producer as each result arrives. By default every result is accepted.
  • Returns the accepted results, in completion order, once need of them are accepted, and cancels the rest.
  • With need: 'all' it returns when every job has finished, with the accepted results only.
  • If need can no longer be reached, it throws TaskFailedError.
runstate.setup(opts: {
limits?: Record<string, { max: number; per: number | string }>;
slots?: Record<string, { max: number }>;
budgets?: Record<string, { limit: number | string; currency?: string; scale?: number }>;
}): Promise<void>;

setup() creates or verifies resources without calling anything or spending units. It is the simplest way to declare the resources a queue names. Each entry follows the same rules as max/per on limit, max on slots and limit on budget: created if missing, ConflictError if the sizes differ.

await runstate.setup({
limits: { 'search-api': { max: 60, per: 'minute' } },
slots: { browser: { max: 5 } },
budgets: { 'model-usd': { limit: 50, currency: 'USD', scale: 2 } },
});
runstate.sleep(name: string, duration: number | string | { until: Date }): Promise<void>;
runstate.together(name: string, count: number, opts?: { timeout?: number | string }): Promise<void>;
await runstate.sleep('cooldown:acme', '10m'); // or { until: new Date(...) }
await runstate.together('stage-2', 5, { timeout: '5m' });
  • sleep(name, duration) is a durable timer in the run. A process that restarts and calls the same name waits only for what is left. Throws StoppedError if the run stops first.
  • Sleep names are per run. A name that already fired returns at once when used again in the same run, so give each distinct wait its own name.
  • together(name, count) returns once count different workers have called it with that name. Workers are counted by their worker label (default <hostname>-<pid>), so a restarted process with the same label counts once. A worker that arrives after the group already went through continues immediately.
  • together waits without a limit unless you pass timeout. When the timeout passes, it throws TimeoutError.
runstate.events(opts?: { types?: string[]; signal?: AbortSignal }): AsyncIterable<RunEvent>;
runstate.status(): Promise<RunStatus>;
interface RunEvent {
id: string;
type: string;
at: string; // ISO timestamp
data: Record<string, unknown>; // the event's payload
}
interface RunStatus {
run: { id: string; name: string; state: 'running' | 'stopped' | 'finished' | 'expired'; deadline: string | null };
workers: { worker: string; holding: number; leaseExpiresAt: string | null }[]; // holding: how many things the worker holds
waiting: { kind: 'slots'; name: string; units: number }[];
}
for await (const event of runstate.events({ types: ['task.failed'] })) {
console.log(event.type, event.data);
}
const status = await runstate.status(); // { run, workers, waiting }
  • events() streams the current run’s events and resumes where it left off after network errors. Stop it by aborting signal or breaking out of the loop.
  • status() returns the run’s state and deadline, the workers currently holding work, and what is waiting. workers and waiting are empty when the key lacks the usage permission.

See Events and monitoring for event types.

Every error is a RunstateError with these fields:

class RunstateError extends Error {
readonly code: string;
readonly status: number;
readonly requestId: string | null;
readonly retryAfter: number | null; // seconds
readonly retryable: boolean; // true for contention the SDK normally waits out
}
function isRunstateError(err: unknown): err is RunstateError;
Class When
ConfigError Missing key or space, bad durations or amounts, arguments that can’t be hashed.
AuthError Bad key, missing permission, suspended space, plan limit reached.
StoppedError The run was stopped, finished or passed its deadline.
TimeoutError A wait (limit, slots, take, together, a job result) ran out.
OnceFailedError A once function failed attempts times. Has attempts and message.
TaskFailedError A queue job failed, or a gather can’t reach need. Has jobId and reason.
BudgetExceededError A reservation doesn’t fit in the budget.
ResultTooLargeError An input or result is over 64 KB.
ConflictError Sizes in code don’t match the existing resource, or a queue’s attempts differ between processes.
LeaseLostError The process lost its hold on the work because someone else took it over. For queue jobs, the result is dropped quietly instead.
UnavailableError runstate couldn’t be reached after retries.

Busy resources, rate limits and plan concurrency limits are retried inside the SDK, honouring Retry-After. They never surface as errors unless a timeout runs out. See Errors & retries.

import { BudgetExceededError, StoppedError } from 'runstate-sdk';
try {
await ask(prompt);
} catch (err) {
if (err instanceof BudgetExceededError) return null; // the call never started
if (err instanceof StoppedError) return null; // the run is over
throw err;
}