Skip to content
HomeConsoleGet started

createRunstateClient, a thin promise-per-endpoint client for infrastructure authors, with no lease renewal or waiting helpers. TypeScript only.

Most code should use the Runstate client. It renews leases, waits for capacity, maps errors to typed classes and resolves names to ids. createRunstateClient does none of that: each method is one HTTP request. It exists for tools built directly on the API, where you want to control every request, including its Idempotency-Key.

import { createRunstateClient, KernelError } from 'runstate-sdk';
const client = createRunstateClient({
baseUrl: process.env.RUNSTATE_BASE_URL ?? 'https://api.getrunstate.com',
apiKey: process.env.RUNSTATE_API_KEY!,
});
const space = client.space(process.env.RUNSTATE_SPACE_ID!);
const run = await space.createScope({ idempotencyKey: `nightly-${new Date().toISOString().slice(0, 10)}` });
const mailbox = await space.createMailbox({ name: 'crawl', idempotencyKey: 'create-crawl-queue' });
await space.mailbox(mailbox.id).send({
scopeId: run.id,
payload: { url: 'https://example.com' },
idempotencyKey: 'crawl-example.com',
});
const msg = await space.mailbox(mailbox.id).recv<{ url: string }>({
scopeId: run.id,
holder: 'crawler-1',
session: 'crawler-1-boot-42',
leaseSeconds: 30,
});
if (msg !== null) {
try {
await msg.renew(60); // nothing renews for you
await msg.complete();
} catch (err) {
if (err instanceof KernelError) console.error(err.code, err.httpStatus);
throw err;
}
}
  • You pass identity explicitly. holder and session go on every claim, receive and acquire. Use a fresh session per process.
  • Ids, not names. Methods take mailbox, permit and barrier ids.
  • No renewal, no waiting. Leases expire unless you call renew(). Busy and exhausted responses are thrown immediately.
  • Idempotency keys are yours. Pass idempotencyKey on the create and send methods that accept it. The client doesn’t generate one, and the API rejects creates and sends without one (VALIDATION_FAILED).
  • Errors are KernelError (with code and httpStatus), not RunstateError subclasses. Codes the client doesn’t recognize become UNAVAILABLE.
  • Retries. Transport failures are retried up to 3 times with 25–100 ms jitter; error responses are not retried.
  • Smaller surface. It covers runs, claims, mailboxes and messages, events, timers, permits and grants, and barriers. Tasks, task groups, quotas, work limits and budgets are only in Runstate and the HTTP API.
function createRunstateClient(options: { baseUrl: string; apiKey: string; fetchImpl?: typeof fetch }): RunstateClient;
interface RunstateClient {
space(spaceId: string): SpaceClient;
}
interface SpaceClient {
createScope(opts: { parentId?: string; deadline?: string; childLimit?: number; idempotencyKey?: string }): Promise<{ id: string; state: string; version: string }>;
scope(scopeId: string): {
get(): Promise<{ id: string; storedState: string; effective: string }>;
cancel(): Promise<{ id: string; state: string; version: string }>;
complete(): Promise<{ id: string; state: string; version: string }>;
};
claim(key: string, opts: { scopeId: string; holder: string; session: string; leaseSeconds?: number; idempotencyKey?: string }): Promise<ClaimHandle>;
createMailbox(opts: { name: string; mode?: 'WORK' | 'INBOX'; backlogLimit?: number; idempotencyKey?: string }): Promise<{ id: string; name: string; mode: string }>;
mailbox(mailboxId: string): {
send(opts: { scopeId: string; payload: unknown; workKey?: string; deadline?: string; idempotencyKey?: string }): Promise<{ id: string; replay: boolean }>;
recv<T = unknown>(opts: { scopeId: string; holder: string; session: string; leaseSeconds?: number }): Promise<MessageHandle<T> | null>;
};
events(opts?: { cursor?: string; limit?: number }): Promise<EventPage>;
armTimer(opts: { scopeId: string; at?: string; afterSeconds?: number; idempotencyKey?: string }): Promise<{ id: string; fireAt: string }>;
pollTimers(scopeId?: string): Promise<FiredTimer[]>;
ackTimer(timerId: string, token: string): Promise<{ state: string }>;
cancelTimer(timerId: string): Promise<{ state: string }>;
createPermit(opts: { name: string; units: number; idempotencyKey?: string }): Promise<{ id: string; name: string; unitsTotal: number }>;
acquirePermit(permitId: string, opts: { scopeId: string; units: number; holder: string; session: string; leaseSeconds?: number; waiterId?: string }): Promise<GrantAcquired>;
waitPermit(permitId: string, opts: { scopeId: string; units: number; holder: string }): Promise<{ waiterId: string }>;
releaseGrant(grantId: string, token: string, observation?: unknown): Promise<{ state: string }>;
createBarrier(opts: { scopeId: string; target: number; idempotencyKey?: string }): Promise<{ id: string }>;
arriveBarrier(barrierId: string, arrivalKey: string): Promise<{ state: string; arrivals: number }>;
barrier(barrierId: string): Promise<BarrierView>;
}
interface ClaimHandle {
key: string;
generation: string;
leaseExpiresAt: string;
token: string;
renew(leaseSeconds?: number): Promise<{ leaseExpiresAt: string }>;
release(observation?: unknown): Promise<{ state: string }>;
}
interface MessageHandle<T = unknown> {
message: { id: string; scopeId: string; payload: T; attempt: number; deadline: string | null };
token: string;
generation: string;
leaseExpiresAt: string;
renew(leaseSeconds?: number): Promise<{ leaseExpiresAt: string }>;
nack(retry: boolean): Promise<{ state: string }>;
complete(result?: { mailboxId: string; scopeId: string; payload: unknown; workKey?: string }): Promise<{ state: string; resultMessageId?: string }>;
}
interface GrantAcquired { grantId: string; token: string; leaseExpiresAt: string }
interface FiredTimer { id: string; token: string; firedAt: string; scopeId: string }
interface BarrierView { id: string; state: string; epoch: string; arrivals: number; target: number }

The low-level complete() type requires mailboxId and scopeId when you pass a result. To record a task outcome without publishing a result message, use delivery.complete({ payload }) from the Runstate client or the HTTP API.