TypeScript SDK
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-sdkrunstate-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 argumentsconst 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 callawait runstate.stop(); // every worker stops taking new workThe 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.
Configuration
Section titled “Configuration”Environment variables
Section titled “Environment variables”| 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.
configure and Runstate
Section titled “configure and Runstate”import runstate, { Runstate } from 'runstate-sdk';
runstate.configure({ apiKey, spaceId, baseUrl, run: 'nightly' }); // change the default clientconst 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 and amounts
Section titled “Durations and amounts”- Durations are a number of seconds or a string:
'500ms','30s','10m','2h','1d'. per(forlimit) 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’sscale. Numbers are rounded half up to the scale, so float noise like0.1 + 0.2becomes0.30. A string with more decimal places than the scale is aConfigError.
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 runawait nightly.once(fetchPrices)('eur');
const child = runstate.child('retry-batch', { deadline: '10m' }); // a child of the current runawait runstate.stop(); // stop the current run: nothing new starts anywhereawait runstate.finish(); // mark it finishedrun(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
onceresults 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()andfinish()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 freshrunstate.run(name)client, joins the next run. - When a run stops, every wrapped call in flight sees
context().signalabort,serve()stops taking work and drains, and new calls throwStoppedError.
context
Section titled “context”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
attemptsfailures every caller getsOnceFailedErrorwith 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, not1.50).NaNandInfinityare aConfigError. - Anything else (functions, class instances, dates) is a
ConfigError. Passkeyinstead. - An anonymous function without
nameis aConfigError. 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.
limit and take
Section titled “limit and take”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.
maxandperdeclare the limit. If it doesn’t exist, it is created (the key needs theresource_configpermission). If it exists with different sizes, the call throwsConflictError. Omit both to use a limit created in the console.- If no unit arrives within
timeout, the call throwsTimeoutError. - 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.
maxdeclares the slots the same way aslimit: created if missing,ConflictErrorif the size differs.- If no slot frees up within
timeout, the call throwsTimeoutError.
budget
Section titled “budget”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,
reserveis held. If the budget can’t cover it, the function is not called andBudgetExceededErroris 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. Pickreserveas your worst case to avoid this. costmay return a number or a decimal string. Returning0releases everything. Withoutcost, the fullreserveis charged when the call succeeds.limitdeclares the budget: created if missing,ConflictErrorif 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 workersexport 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,});
// producerconst score = await review(doc); // submit and wait for the resultconst job = await review.send(doc); // submit onlyconsole.log(job.id);const later = await job.result({ timeout: '5m' });
// worker processawait 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
keyto 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
attemptsis reached. Then callers getTaskFailedErrorwith the last error message. - If the worker dies, the job goes back to the queue after its lease lapses.
review.queueNameis the queue’s name.slotsandlimittake a name or{ name, units }.budgettakes{ name, reserve, cost? }. Withoutcost, the job is charged its fullreservewhen it succeeds and nothing when it fails.- The limit, slots and budget a queue names must already exist. Declare them with
setup, withlimit,slotsorbudgetelsewhere, or in the console. job.result()waits without a limit by default. Withtimeout, it throwsTimeoutErrorwhen 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.
Producer-only queues
Section titled “Producer-only queues”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 });gather
Section titled “gather”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.
needis'all'or a whole number from 1 to the number of inputs.acceptruns in the producer as each result arrives. By default every result is accepted. - Returns the accepted results, in completion order, once
needof them are accepted, and cancels the rest. - With
need: 'all'it returns when every job has finished, with the accepted results only. - If
needcan no longer be reached, it throwsTaskFailedError.
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 } },});sleep and together
Section titled “sleep and together”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. ThrowsStoppedErrorif 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 oncecountdifferent workers have called it with that name. Workers are counted by theirworkerlabel (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.togetherwaits without a limit unless you passtimeout. When the timeout passes, it throwsTimeoutError.
events and status
Section titled “events and status”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 abortingsignalor breaking out of the loop.status()returns the run’s state and deadline, the workers currently holding work, and what is waiting.workersandwaitingare empty when the key lacks the usage permission.
See Events and monitoring for event types.
Errors
Section titled “Errors”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;}