Skip to content

npm npm Downloads CI Recovery conformance License Coverage

Cycles TypeScript Client — AI agent budget and action authority SDK

TypeScript/Node.js SDK for AI agent budget governance — enforce cost limits, tool permissions, and multi-tenant policies before LLM calls or agent actions execute. Works with OpenAI, Anthropic, MCP servers, OpenAI Agents SDK, LangChain.js, and any Node.js agent runtime.

Higher-order function and AsyncLocalStorage-based API for the Cycles Protocol: reserve capacity before expensive operations, execute your work, commit or release — with automatic heartbeats, retries, and typed error handling. Install via npm install runcycles.

Requirements

  • Node.js 20+ (uses built-in fetch and AsyncLocalStorage)
  • TypeScript 5+ (for type definitions; optional — works with plain JavaScript)

Installation

npm install runcycles

Quick Start

1. Higher-order function (recommended)

Wrap any async function with withCycles to automatically reserve, execute, and commit:

import { CyclesClient, CyclesConfig, withCycles, getCyclesContext } from "runcycles";

const config = new CyclesConfig({
  baseUrl: "http://localhost:7878",
  apiKey: "your-api-key",
  tenant: "acme",
});
const client = new CyclesClient(config);

const callLlm = withCycles(
  {
    estimate: (prompt: string, tokens: number) => tokens * 10,
    actual: (result: string) => result.length * 5,
    actionKind: "llm.completion",
    actionName: "gpt-4",
    client,
  },
  async (prompt: string, tokens: number) => {
    const ctx = getCyclesContext();
    if (ctx?.caps) {
      tokens = Math.min(tokens, ctx.caps.maxTokens ?? tokens);
    }

    const result = `Response to: ${prompt}`;

    if (ctx) {
      ctx.metrics = { tokensInput: tokens, tokensOutput: result.length };
    }

    return result;
  },
);

const result = await callLlm("Hello", 100);

Need an API key? API keys are created via the Cycles Admin Server (port 7979). See the deployment guide to create one, or run:

curl -s -X POST http://localhost:7979/v1/admin/api-keys \
  -H "Content-Type: application/json" \
  -H "X-Admin-API-Key: admin-bootstrap-key" \
  -d '{"tenant_id":"acme-corp","name":"dev-key","permissions":["reservations:create","reservations:commit","reservations:release","reservations:extend","reservations:list","balances:read","decide","events:create"]}' | jq -r '.key_secret'

The key (e.g. cyc_live_abc123...) is shown only once — save it immediately. For key rotation and lifecycle details, see API Key Management.

What happens: withCycles reserves budget before calling your function, runs it inside an async context (so getCyclesContext() works), commits the actual cost on success, or releases the reservation on failure. A background heartbeat keeps the reservation alive.

Budget lifecycle

Scenario Outcome Detail
Reservation denied Neither BudgetExceededError, OverdraftLimitExceededError, or DebtOutstandingError thrown; function never executes
dryRun: true, any decision Neither Returns DryRunResult or throws; no real reservation created
Function returns successfully Commit Actual amount charged; unused remainder auto-released
Guarded function throws Release Full reserved amount returned to budget; error re-thrown
Post-action settlement setup throws Neither Error surfaces, but known spend is never released
actual callback throws or returns an invalid amount Commit estimate Commit carries metadata.actual_source="estimate"
Commit fails (5xx / network) Retry Exponential backoff with configurable attempts
Commit fails (recognized non-retryable 4xx) Neither Retry stops and the journal entry is discarded, but known spend is never released
Commit gets RESERVATION_EXPIRED Event recovery Spend is recorded through POST /v1/events because the server already reclaimed the expired reservation
Commit gets RESERVATION_FINALIZED Neither Already committed or released (idempotent replay)
Commit gets IDEMPOTENCY_MISMATCH Neither Previous commit already processed; no release attempted

Streaming (reserveForStream): Call handle.commit(actual) on success or handle.release(reason) on failure. If neither is called, the server reclaims the budget when the reservation TTL expires.

All thrown errors from the guarded function trigger release. See How Reserve-Commit Works for the full protocol-level explanation.

2. Streaming adapter

For LLM streaming where usage is only known after the stream finishes:

import { CyclesClient, CyclesConfig, reserveForStream } from "runcycles";

const config = new CyclesConfig({
  baseUrl: "http://localhost:7878",
  apiKey: "your-api-key",
  tenant: "acme",
});
const client = new CyclesClient(config);

let handle;
try {
  handle = await reserveForStream({
    client,
    estimate: 5000,
    unit: "USD_MICROCENTS",
    actionKind: "llm.completion",
    actionName: "gpt-4o",
  });
} catch (err) {
  // Reservation denied (BudgetExceededError, etc.) — no cleanup needed
  throw err;
}

try {
  // Start streaming (e.g. Vercel AI SDK's streamText)
  const stream = streamText({
    model: openai("gpt-4o"),
    messages,
    onFinish: async ({ usage }) => {
      const actualCost = (usage.promptTokens + usage.completionTokens) * 3;
      // commit() automatically stops the heartbeat
      await handle.commit(actualCost, {
        tokensInput: usage.promptTokens,
        tokensOutput: usage.completionTokens,
      });
    },
  });

  return stream.toDataStreamResponse();
} catch (err) {
  // Stream startup failed — release and stop heartbeat
  await handle.release("stream_error");
  throw err;
}

The handle is once-only and race-safe: in streaming code, multiple terminal paths can fire concurrently (onFinish, error handler, abort signal). Only the first terminal call wins:

  • commit() throws CyclesError if already finalized (dropping a commit silently hides bugs). Transient commit failures are durably queued and resolve normally. A recognized terminal rejection throws but leaves finalized true, so a broad catch cannot release known spend.
  • An invalid commit(actual) amount falls back to the reserved estimate and adds metadata.actual_source="estimate"; the handle never journals an invalid amount after the stream ran.
  • release() is a silent no-op if already finalized (best-effort by design)
  • dispose() stops the heartbeat only, for startup failures before streaming begins
  • handle.finalized — check whether the handle has been finalized

3. Programmatic client

Use CyclesClient directly for full control. The client operates on wire-format (snake_case) JSON. Use typed mappers for camelCase convenience, or pass raw snake_case objects:

import {
  CyclesClient,
  CyclesConfig,
  reservationCreateRequestToWire,
  reservationCreateResponseFromWire,
  commitRequestToWire,
  commitResponseFromWire,
} from "runcycles";

const config = new CyclesConfig({ baseUrl: "http://localhost:7878", apiKey: "your-api-key" });
const client = new CyclesClient(config);

// 1. Reserve budget (using typed request mapper)
const response = await client.createReservation(
  reservationCreateRequestToWire({
    idempotencyKey: "req-001",
    subject: { tenant: "acme", agent: "support-bot" },
    action: { kind: "llm.completion", name: "gpt-4" },
    estimate: { unit: "USD_MICROCENTS", amount: 500_000 },
    ttlMs: 30_000,
  }),
);

if (response.isSuccess) {
  // Parse typed response
  const parsed = reservationCreateResponseFromWire(response.body!);

  // 2. Do work ...

  // 3. Commit actual usage (using typed request mapper)
  const commitResp = await client.commitReservation(
    parsed.reservationId!,
    commitRequestToWire({
      idempotencyKey: "commit-001",
      actual: { unit: "USD_MICROCENTS", amount: 420_000 },
      metrics: { tokensInput: 1200, tokensOutput: 800 },
    }),
  );

  if (commitResp.isSuccess) {
    const commit = commitResponseFromWire(commitResp.body!);
    console.log(`Charged: ${commit.charged.amount}, Released: ${commit.released?.amount}`);
  }
}

You can also pass raw snake_case objects directly without mappers:

const response = await client.createReservation({
  idempotency_key: "req-001",
  subject: { tenant: "acme", agent: "support-bot" },
  action: { kind: "llm.completion", name: "gpt-4" },
  estimate: { unit: "USD_MICROCENTS", amount: 500_000 },
  ttl_ms: 30_000,
});

Which pattern to use?

Pattern Use when
withCycles You have an async function that returns a result — the lifecycle is fully automatic
reserveForStream You're streaming (e.g., LLM streaming) and usage is known only after the stream finishes
CyclesClient You need full control over the reservation lifecycle, or are building custom integrations

Configuration

Constructor options

new CyclesConfig({
  // Required
  baseUrl: "http://localhost:7878",
  apiKey: "your-api-key",

  // Default subject fields (applied to all requests unless overridden)
  tenant: "acme",
  workspace: "prod",
  app: "chat",
  workflow: "refund-flow",
  agent: "planner",
  toolset: "search-tools",

  // Timeouts (ms) — summed into a single fetch AbortSignal timeout
  connectTimeout: 2_000,   // default: 2000
  readTimeout: 5_000,      // default: 5000

  // Commit retry (exponential backoff for failed commits)
  retryEnabled: true,       // default: true
  retryMaxAttempts: 5,      // default: 5
  retryInitialDelay: 500,   // default: 500 (ms)
  retryMultiplier: 2.0,     // default: 2.0
  retryMaxDelay: 30_000,    // default: 30000 (ms)
  retryFlushTimeout: 10_000, // default: 10000 (ms) — flush() bound

  // Commit durability (on-disk journal, survives process restarts)
  journalEnabled: true,      // default: true
  journalDir: undefined,     // default: ~/.runcycles/commit-journal
});

Timeout note: Node's built-in fetch does not distinguish connection timeout from read timeout. connectTimeout and readTimeout are summed into a single AbortSignal.timeout() value (default: 7000ms total) that caps the entire request duration.

Environment variables

import { CyclesConfig } from "runcycles";

const config = CyclesConfig.fromEnv();

fromEnv() reads these environment variables (all prefixed with CYCLES_ by default):

Variable Required Description
CYCLES_BASE_URL Yes Cycles server URL
CYCLES_API_KEY Yes API key for authentication (see how to create one)
CYCLES_TENANT No Default tenant
CYCLES_WORKSPACE No Default workspace
CYCLES_APP No Default app
CYCLES_WORKFLOW No Default workflow
CYCLES_AGENT No Default agent
CYCLES_TOOLSET No Default toolset
CYCLES_CONNECT_TIMEOUT No Connect timeout in ms (default: 2000)
CYCLES_READ_TIMEOUT No Read timeout in ms (default: 5000)
CYCLES_RETRY_ENABLED No Enable commit retry (default: true)
CYCLES_RETRY_MAX_ATTEMPTS No Max retry attempts (default: 5)
CYCLES_RETRY_INITIAL_DELAY No Initial retry delay in ms (default: 500)
CYCLES_RETRY_MULTIPLIER No Backoff multiplier (default: 2.0)
CYCLES_RETRY_MAX_DELAY No Max retry delay in ms (default: 30000)

Custom prefix: CyclesConfig.fromEnv("MYAPP_") reads MYAPP_BASE_URL, MYAPP_API_KEY, etc.

Default client / config

Instead of passing client to every withCycles call, set a module-level default:

import { CyclesConfig, setDefaultConfig, setDefaultClient, CyclesClient, withCycles } from "runcycles";

// Option 1: Set a config (client created lazily)
setDefaultConfig(new CyclesConfig({ baseUrl: "http://localhost:7878", apiKey: "your-key", tenant: "acme" }));

// Option 2: Set an explicit client
setDefaultClient(new CyclesClient(new CyclesConfig({ baseUrl: "http://localhost:7878", apiKey: "your-key" })));

// Now withCycles works without client
const guarded = withCycles({ estimate: 1000 }, async () => "hello");

Client resolution is deferred to the first invocation and then cached — the wrapper binds permanently to the resolved client after its first call. A later setDefaultClient() call will not affect already-invoked wrappers.

withCycles Options

The WithCyclesConfig interface controls the lifecycle behavior:

interface WithCyclesConfig {
  // Cost estimation — required
  estimate: number | ((...args) => number);  // Estimated cost (static or computed from args)

  // Actual cost — optional (defaults to estimate if not provided)
  actual?: number | ((result) => number);    // Actual cost (static or computed from result).
                                             // Callback failure/invalid output after the action
                                             // safely falls back to the estimate.
  useEstimateIfActualNotProvided?: boolean;  // Default: true — use estimate as actual.
                                             // When this fallback is taken the commit carries
                                             // metadata.actual_source = "estimate" so estimated
                                             // spend is distinguishable from measured spend.

  // Action identification (static or computed from args)
  actionKind?: string | ((...args) => string | undefined);  // e.g. "llm.completion" (default: "unknown")
  actionName?: string | ((...args) => string | undefined);  // e.g. "gpt-4" (default: "unknown")
  actionTags?: string[]; // Optional tags for categorization

  // Budget unit
  unit?: string;  // default: "USD_MICROCENTS"

  // Reservation settings
  ttlMs?: number;          // Time-to-live in ms (default: 60000, range: 1000–86400000)
  gracePeriodMs?: number;  // Grace period in ms (range: 0–60000)
  overagePolicy?: string;  // "ALLOW_IF_AVAILABLE" (default), "REJECT", "ALLOW_WITH_OVERDRAFT"
  dryRun?: boolean;        // Shadow mode — evaluates budget without executing

  // Subject fields (override config defaults; static or computed from args)
  tenant?: string | ((...args) => string | undefined);
  workspace?: string | ((...args) => string | undefined);
  app?: string | ((...args) => string | undefined);
  workflow?: string | ((...args) => string | undefined);
  agent?: string | ((...args) => string | undefined);
  toolset?: string | ((...args) => string | undefined);
  dimensions?: Record<string, string>;  // Custom key-value dimensions

  // Client
  client?: CyclesClient;  // Override the default client
}

A callable returning undefined falls through to the client-config default for subject fields, or to "unknown" for actionKind / actionName — same fallback semantics as a missing static. Callables run before the reservation is created; if one throws, the reservation is never attempted and the error propagates to the caller.

Dynamic subject and action fields

Derive the subject scope or action identity from per-call arguments:

const runRequest = withCycles(
  {
    estimate: (req, workspaceId) => req.tokens * 10,
    workspace: (_req, workspaceId) => workspaceId,
    actionKind: "llm.completion",
    actionName: (req) => req.model,
    client,
  },
  async (req: { tokens: number; model: string }, workspaceId: string) => {
    // ... the reservation routes to this workspaceId ...
    return callLLM(req);
  },
);

Context Access

Inside a withCycles-guarded function, access the active reservation via getCyclesContext():

import { getCyclesContext } from "runcycles";

const guarded = withCycles({ estimate: 1000, client }, async () => {
  const ctx = getCyclesContext();

  // Read reservation details (read-only)
  ctx?.reservationId;    // Server-assigned reservation ID
  ctx?.estimate;         // The estimated amount
  ctx?.decision;         // "ALLOW" or "ALLOW_WITH_CAPS"
  ctx?.caps;             // Soft-landing caps (maxTokens, toolAllowlist, etc.)
  ctx?.expiresAtMs;      // Reservation expiry (updated by heartbeat)
  ctx?.affectedScopes;   // Budget scopes affected
  ctx?.scopePath;        // Scope path for this reservation
  ctx?.reserved;         // Amount reserved
  ctx?.balances;         // Balance snapshots

  // Set metrics (included in the commit)
  if (ctx) {
    ctx.metrics = { tokensInput: 50, tokensOutput: 200, modelVersion: "gpt-4o" };
    ctx.commitMetadata = { requestId: "abc", region: "us-east-1" };
  }

  return "result";
});

The context uses AsyncLocalStorage, so it's available in any nested async call within the guarded function.

Latency tracking: If ctx.metrics.latencyMs is not set, withCycles automatically sets it to the execution time of the guarded function.

Error Handling

With withCycles or reserveForStream

Protocol errors are thrown as typed exceptions:

import {
  withCycles,
  BudgetExceededError,
  CyclesProtocolError,
} from "runcycles";

const guarded = withCycles({ estimate: 1000, client }, async () => "result");

try {
  await guarded();
} catch (err) {
  if (err instanceof BudgetExceededError) {
    console.log("Budget exhausted — degrade or queue");
  } else if (err instanceof CyclesProtocolError) {
    // Use helper methods for cleaner checks
    if (err.isBudgetExceeded()) { /* ... */ }
    if (err.isOverdraftLimitExceeded()) { /* ... */ }
    if (err.isDebtOutstanding()) { /* ... */ }
    if (err.isReservationExpired()) { /* ... */ }
    if (err.isReservationFinalized()) { /* ... */ }
    if (err.isIdempotencyMismatch()) { /* ... */ }
    if (err.isUnitMismatch()) { /* ... */ }
    if (err.isTenantClosed()) { /* ... */ }

    // Retry handling
    if (err.isRetryable() && err.retryAfterMs) {
      console.log(`Retry after ${err.retryAfterMs}ms`);
    }

    // Error details
    console.log(err.errorCode);   // e.g. "BUDGET_EXCEEDED"
    console.log(err.reasonCode);  // Server-provided reason
    console.log(err.requestId);   // For support/debugging
    console.log(err.details);     // Additional error context
    console.log(err.status);      // HTTP status code (-1 for transport failures)

    // Transport failures (network error — the server was never reached)
    if (err.status === -1) {
      console.log("Network error:", err.message);
    }
  }
}

Exception hierarchy

Exception When
CyclesError Base for all Cycles errors
CyclesProtocolError Server returned a protocol-level error; also thrown with status === -1 when a helper surface wraps a reservation-time transport failure
BudgetExceededError Budget insufficient for the reservation
OverdraftLimitExceededError Debt exceeds the overdraft limit
DebtOutstandingError Outstanding debt blocks new reservations
ReservationExpiredError Operating on an expired reservation
ReservationFinalizedError Operating on an already-committed/released reservation
TenantClosedError The owning tenant is CLOSED (HTTP 409 TENANT_CLOSED, runtime spec v0.1.25.13); thrown at reservation time by withCycles / reserveForStream — commit-time client errors never release known spend, and StreamReservation.commit() throws generic CyclesError for recognized terminal rejection
CyclesTransportError Exported for use in your own code; never thrown by the SDK — transport failures surface as status === -1 (see below)

Transport failures (status -1)

Transport failures (DNS failure, timeout, connection refused) do not surface as a distinct exception class. The SDK never throws CyclesTransportError itself — the class is exported for use in your own code (e.g. wrapping transport-level failures in higher-level integrations). Instead:

  • withCycles / reserveForStream throw CyclesProtocolError with status === -1 and errorCode undefined for reservation-time transport failures. Commit-time transient failures are journaled for recovery. StreamReservation.commit() throws only for recognized terminal rejection and remains finalized so the caller cannot release known spend.
  • Programmatic CyclesClient calls never throw on transport failure — they return a CyclesResponse with isTransportError set and status of -1 (see below).

With CyclesClient (programmatic)

The client returns CyclesResponse instead of throwing:

const response = await client.createReservation({ /* ... */ });

if (response.isTransportError) {
  console.log("Network error:", response.errorMessage);
  console.log("Underlying error:", response.transportError);
} else if (!response.isSuccess) {
  console.log(`HTTP ${response.status}: ${response.errorMessage}`);
  console.log(`Request ID: ${response.requestId}`);

  // Parse structured error
  const err = response.getErrorResponse();
  if (err) {
    console.log(`Error code: ${err.error}, Message: ${err.message}`);
    console.log(`Details:`, err.details);
  }
}

Response Metadata

Every CyclesResponse exposes server headers:

const response = await client.createReservation({ /* ... */ });

response.requestId;          // X-Request-Id — for tracing/debugging
response.rateLimitRemaining; // X-RateLimit-Remaining — requests left in window
response.rateLimitReset;     // X-RateLimit-Reset — epoch seconds when window resets
response.cyclesTenant;       // X-Cycles-Tenant — resolved tenant

// Status checks
response.isSuccess;       // 2xx
response.isClientError;   // 4xx
response.isServerError;   // 5xx
response.isTransportError; // Network failure (status = -1)

API Reference

CyclesClient Methods

All methods return Promise<CyclesResponse>.

Method Description
createReservation(request) Reserve budget before an operation
commitReservation(reservationId, request) Commit actual usage after completion
releaseReservation(reservationId, request) Release unused reservation
extendReservation(reservationId, request) Extend reservation TTL (heartbeat)
decide(request) Preflight budget check without creating a reservation
createEvent(request) Record spend directly without a reservation (direct debit)
listReservations(params?) List reservations with optional filters
getReservation(reservationId) Get a single reservation's details
getBalances(params) Query budget balances (requires at least one subject filter)

StreamReservation Handle

Returned by reserveForStream():

Property/Method Description
reservationId Server-assigned reservation ID
decision Budget decision (ALLOW or ALLOW_WITH_CAPS)
caps Soft-landing caps, if any
finalized true after any terminal call
commit(actual, metrics?, metadata?) Commit actual usage; throws if already finalized
release(reason?) Release reservation; no-op if already finalized
dispose() Stop heartbeat only, for startup failures

Preflight Checks (decide)

Check if a budget would allow an operation without creating a reservation:

import { decisionRequestToWire, decisionResponseFromWire } from "runcycles";

const response = await client.decide(
  decisionRequestToWire({
    idempotencyKey: "decide-001",
    subject: { tenant: "acme" },
    action: { kind: "llm.completion", name: "gpt-4" },
    estimate: { unit: "USD_MICROCENTS", amount: 500_000 },
  }),
);

if (response.isSuccess) {
  const parsed = decisionResponseFromWire(response.body!);
  console.log(parsed.decision); // "ALLOW", "ALLOW_WITH_CAPS", or "DENY"
  if (parsed.caps) {
    console.log(`Max tokens: ${parsed.caps.maxTokens}`);
  }
}

Use decide() for lightweight checks before committing to work (e.g., showing a user "you have budget remaining" in a UI), or when you want to inspect caps before starting. Unlike createReservation, it doesn't hold any budget.

Events (Direct Debit)

Record spend without a prior reservation (returns HTTP 201):

import { eventCreateRequestToWire, eventCreateResponseFromWire } from "runcycles";

const response = await client.createEvent(
  eventCreateRequestToWire({
    idempotencyKey: "evt-001",
    subject: { tenant: "acme" },
    action: { kind: "api.call", name: "geocode" },
    actual: { unit: "USD_MICROCENTS", amount: 1_500 },
    overagePolicy: "ALLOW_IF_AVAILABLE",
    metrics: { latencyMs: 120 },
    clientTimeMs: Date.now(),
    metadata: { region: "us-east-1" },
  }),
);

if (response.isSuccess) {
  const parsed = eventCreateResponseFromWire(response.body!);
  console.log(`Event ID: ${parsed.eventId}, Status: ${parsed.status}`);
}

Use events for fast, low-value operations where the reserve/commit overhead isn't justified (e.g., simple API calls, cache lookups, tool invocations with known costs).

Querying

Balances

At least one subject filter is required:

import { balanceResponseFromWire } from "runcycles";

const response = await client.getBalances({ tenant: "acme" });
if (response.isSuccess) {
  const parsed = balanceResponseFromWire(response.body!);
  for (const balance of parsed.balances) {
    console.log(`${balance.scopePath}: remaining=${balance.remaining.amount}`);
    console.log(`  reserved=${balance.reserved?.amount}, spent=${balance.spent?.amount}`);
    console.log(`  allocated=${balance.allocated?.amount}`);
    if (balance.isOverLimit) {
      console.log(`  OVER LIMIT — debt: ${balance.debt?.amount}, limit: ${balance.overdraftLimit?.amount}`);
    }
  }
  // Pagination
  if (parsed.hasMore) {
    const next = await client.getBalances({ tenant: "acme", cursor: parsed.nextCursor! });
    // ...
  }
}

Query filters: tenant, workspace, app, workflow, agent, toolset, include_children, limit, cursor.

Reservations

import { reservationListResponseFromWire, reservationDetailFromWire } from "runcycles";

// List reservations (filters: tenant, workspace, app, workflow, agent, toolset, status, idempotency_key, limit, cursor)
const list = await client.listReservations({ tenant: "acme", status: "ACTIVE" });
if (list.isSuccess) {
  const parsed = reservationListResponseFromWire(list.body!);
  for (const r of parsed.reservations) {
    console.log(`${r.reservationId}: ${r.status}${r.reserved.amount} ${r.reserved.unit}`);
  }
  if (parsed.hasMore) {
    const next = await client.listReservations({ tenant: "acme", cursor: parsed.nextCursor! });
  }
}

// Get a specific reservation
const detail = await client.getReservation("r-123");
if (detail.isSuccess) {
  const parsed = reservationDetailFromWire(detail.body!);
  console.log(`Status: ${parsed.status}`);
  console.log(`Reserved: ${parsed.reserved.amount}, Committed: ${parsed.committed?.amount}`);
  console.log(`Created: ${parsed.createdAtMs}, Expires: ${parsed.expiresAtMs}`);
  console.log(`Finalized: ${parsed.finalizedAtMs}`);
}

Release and Extend

import { releaseRequestToWire, releaseResponseFromWire } from "runcycles";
import { reservationExtendRequestToWire, reservationExtendResponseFromWire } from "runcycles";

// Release a reservation
const releaseResp = await client.releaseReservation(
  "r-123",
  releaseRequestToWire({ idempotencyKey: "rel-001", reason: "user_cancelled" }),
);
if (releaseResp.isSuccess) {
  const parsed = releaseResponseFromWire(releaseResp.body!);
  console.log(`Released: ${parsed.released.amount}`);
}

// Extend a reservation TTL (heartbeat)
const extendResp = await client.extendReservation(
  "r-123",
  reservationExtendRequestToWire({ idempotencyKey: "ext-001", extendByMs: 30_000 }),
);
if (extendResp.isSuccess) {
  const parsed = reservationExtendResponseFromWire(extendResp.body!);
  console.log(`New expiry: ${parsed.expiresAtMs}`);
}

Dry Run (Shadow Mode)

Test budget evaluation without executing the guarded function:

import type { DryRunResult } from "runcycles";

const guarded = withCycles(
  { estimate: 1000, dryRun: true, client },
  async () => "result",
);

const dryResult = await guarded() as unknown as DryRunResult;
console.log(dryResult.decision);      // "ALLOW", "ALLOW_WITH_CAPS", or throws on "DENY"
console.log(dryResult.caps);          // Caps if ALLOW_WITH_CAPS
console.log(dryResult.reserved);      // Amount that would be reserved
console.log(dryResult.affectedScopes);
console.log(dryResult.balances);

Retry Behavior & Commit Durability

A commit records spend that has already happened, so the SDK never lets one exist only in memory. When a commit fails transiently (transport error, 5xx, or 429), the client journals it to disk and schedules background retries with exponential backoff:

  • Durable journal — every scheduled retry is first written to journalDir (default ~/.runcycles/commit-journal) in a per-identity subdirectory (directories 0700, files 0600 where supported) keyed by a non-secret PBKDF2 fingerprint of the server plus principal: the configured tenant when set (stable across API-key rotation), else the API key. Entries are removed only on a terminal outcome; survivors replay automatically the next time a client lifecycle is created for the same identity. The derivation is byte-compatible with the Python SDK, so same-tenant clients in both languages share an identity directory.
  • Backoff formula: min(initialDelay * multiplier^attempt, maxDelay) — defaults to 500ms, 1s, 2s, 4s, 8s
  • Rate limiting: 429 / LIMIT_EXCEEDED is transient everywhere — a rate-limited first commit is scheduled for retry (never released, which would return budget for spend that already happened), and the next attempt waits at least the server's Retry-After. The floor is persisted as an absolute timestamp, so a restart mid-wait still honors it.
  • Authentication failures: 401/403 on any commit attempt journals the spend (never releases it) and stops the current run's attempts, so spend recorded during a key misconfiguration or rotation window replays once credentials are fixed.
  • Expired reservations: a commit answered RESERVATION_EXPIRED (or a bodyless HTTP 410) — where the server has already returned the reserved budget to the pool — is recovered via POST /v1/events (the protocol's post-hoc direct-debit endpoint), reusing the commit's idempotency key and tagging metadata.recovered_reservation_id for reconciliation.
  • Genuine rejections — 4xx responses carrying a recognized protocol error code (e.g. UNIT_MISMATCH) — stop retries and discard the journal entry because retrying cannot fix a malformed commit. They never release the reservation after the guarded action has spent the resource. Codeless, mangled, or unknown (forward-compat) error codes are not treated as rejections: the spend record is journaled and retained for replay instead of being released or discarded.
  • Delay clamp: any server-requested wait (Retry-After, persisted floors) is honored for at most 1 hour.
  • Applies to withCycles and the streaming adapter's handle.commit(); the programmatic client (client.commitReservation) stays manual.
  • Retry timers are ref'd, so a naturally-draining Node process waits for in-flight retries; process.exit(), crashes, and signals are covered by journal replay on the next run. Set journalEnabled: false (or CYCLES_JOURNAL_ENABLED=false) to opt out.

Configure via CyclesConfig:

new CyclesConfig({
  // ...
  retryEnabled: false,        // disable retries entirely
  retryMaxAttempts: 3,        // fewer attempts
  retryInitialDelay: 1000,    // start slower
});

Serverless: flush before the handler returns

Background retries run on timers, and serverless platforms (Lambda, Vercel, Cloud Functions) may freeze or kill the process as soon as your handler resolves. Call flushPendingCommits() to wait — bounded — for all in-flight commit retries across the process (including the engines withCycles and reserveForStream create internally) before returning the response:

import { flushPendingCommits } from "runcycles";

export async function handler(event: unknown) {
  const result = await guardedWork(event);
  // Bounded wait; defaults to the max retryFlushTimeout (10 s) among engines.
  await flushPendingCommits();       // or flushPendingCommits(2_000)
  return result;
}

Anything still pending when the deadline elapses stays journaled and replays on the next warm start.

Heartbeat

Both withCycles and reserveForStream start an automatic heartbeat that extends the reservation TTL while your work runs:

  • Success contract (both scheduling modes): only a complete, schema-valid HTTP 200 create/extend response counts as success. A malformed or non-200 2xx response is ambiguous and is recovered with the same idempotency key; the fallback changes only how the next delay is calculated, not what proves that an operation succeeded.
  • Server-authoritative scheduling (remaining_ttl_ms, spec PR #148) — the normative path. When a create or extend response carries remaining_ttl_ms (remaining lifetime at response evaluation, same clock snapshot as expires_at_ms; present on successful live-reservation responses), the heartbeat schedules from it exactly, recomputing from every schema-valid response alone: lead_floor = max(0, remaining_ttl_ms − rtt) with rtt that attempt's measured monotonic round-trip (max tracked); attempt_budget = max(request timeout, 1s, 2×maxRtt) where the request timeout is the client's enforced per-attempt bound (connectTimeout + readTimeout, applied to every request via AbortSignal.timeout); safety_margin = max(1s, 2×maxRtt); and the next beat fires max(0, lead_floor − (2×attempt_budget + safety_margin)) after response receipt. Only a schema-valid HTTP 200 ReservationExtendResponse counts as an observed success on this path — any other or malformed 2xx is ambiguous and recovered like a transient failure with the same idempotency key. The lead-accounting skip check is bypassed (the schedule is exact), and the first beat is derived from the create response with the create call's own measured rtt instead of firing immediately. A schema-valid success that still yields a zero delay permits exactly one immediate fresh attempt (new key); a second consecutive zero stops the heartbeat and surfaces that the lease is shorter than the retry-safety budget (unreliable timing or an unbounded timeout takes the same path — never a silent downgrade to the fallback). Transient failures (timeout, connection error, 5xx, 429, ambiguous 2xx) recover with the same key inside an unclamped retry_window = current_lead − attempt_budget − safety_margin: negative → stop and surface; otherwise non-429 retries after min(30s, current_lead/4, retry_window), and 429 honors Retry-After exactly and only when it fits the window. Any other 4xx stops without ever rotating the idempotency key. Everything below is the fallback for servers that do not send the field; if the field disappears mid-flight, the fallback takes over seamlessly
  • Measured-grant lead accounting (fallback): the server extends relative to the current expires_at_ms, so each beat computes a rigorous lower bound on the expiry lead — leadMin = grantsSum − elapsed, where grants are differences of successive server-returned expires_at_ms values (same server clock frame) and elapsed is client-monotonic. No cross-clock arithmetic anywhere, and leadMin starts at 0 (the initial grant is never counted — there is no safe same-clock anchor for it), so the bound never overstates the real lead. A beat is skipped only when leadMin ≥ 1.5 × the last measured grant; steady state consumes about one of the server's max_extensions (default 10) per grant of runtime with no outward expiry drift
  • First beat (fallback): immediate (delay 0). Tenant policy max_reservation_ttl_ms can silently cap the granted TTL below the requested one (governance default: 1 hour), and without remaining_ttl_ms any bounded first-beat delay can outlive a small capped lease — so the first extend fires right away and primes the lease with a real, measurable grant. The 0 delay applies to the first schedule only: a transiently failed first attempt retries after the full held interval (below), never hot-loops. The HTTP Date header plays no role anywhere in the heartbeat (CyclesResponse.serverDateMs remains as a general response accessor): per RFC 9110 Date is a whole-second best-effort origination time that intermediaries may replace, and in the reference server expires_at_ms comes from Redis TIME while Date comes from the servlet container — never a safe same-clock pair
  • Cadence (fallback, regime split): a grant may drive the cadence only when it is a real per-extend amount. After such a grant the interval re-derives as clamp(grant/2, 500ms, requestedTtl/2) — e.g., a 60s TTL granted in full heartbeats every 30s, while a 24h request silently capped to 1h-per-extend tightens to 30min beats automatically. But under a maximum-lead clamp (the server holds expires_at_ms ≈ now + L), successive expires_at_ms differences measure elapsed time, not lease — deriving the cadence from them would collapse the interval to the 500ms floor and burn max_extensions in seconds. A grant that is non-positive, or both < 0.9× the requested ttl and within the two-sided 0.75×1.25× band of the elapsed time since the last applied extend, therefore holds the cadence at min(requestedTtl/2, 30s), logs a once-per-heartbeat console.warn (the server appears to clamp lease lead; the extension budget will deplete), and keeps extending every beat. The band is two-sided on purpose: after a leadMin skip the next grant arrives across a doubled gap, so a genuine grant-clamped server also shows grant ≈ elapsed exactly once — it lands in the hold once, then at the held cadence its grant/elapsed ratio falls to ≈0.5, exits the band, and the cadence re-tightens (an upper bound alone would make that hold self-sustain and decay the lease). A real maximum-lead clamp tracks any gap at ratio ≈1 and stays held. The honest limit: regime detection from (grant, elapsed) alone is undecidable in general — any grant in [0.75×min(ttl/2, 30s), 0.9×ttl) reproduces the elapsed gap at the held cadence and misclassifies permanently — which is exactly why remaining_ttl_ms above is the normative path and this heuristic is best-effort for servers clamping only the per-extend delta
  • Extension amount: extend_by_ms equals the full requested ttl_ms each time — the server owns clamping; the client never shrinks its ask based on estimates
  • Best-effort with exactly-once retries: a transiently failed extend is retried on the next beat with the same idempotency key (a lost response cannot double-extend); a success regenerates the key
  • Permanent-failure stop: HTTP 410, RESERVATION_EXPIRED, RESERVATION_FINALIZED, MAX_EXTENSIONS_EXCEEDED, TENANT_CLOSED, or NOT_FOUND stops the heartbeat permanently — no retry can fix those
  • Auto-stop: the heartbeat stops when the reservation is committed, released, or disposed

Validation

The client validates inputs before sending requests:

Field Constraint Error
subject At least one of: tenant, workspace, app, workflow, agent, toolset "Subject must have at least one standard field (tenant, workspace, app, workflow, agent, or toolset)"
estimate Must be >= 0 "estimate must be non-negative"
ttlMs 1,000 – 86,400,000 ms (1s – 24h) "ttl_ms must be between 1000 and 86400000"
gracePeriodMs 0 – 60,000 ms (0 – 60s) "grace_period_ms must be between 0 and 60000"
extendByMs 1 – 86,400,000 ms "extend_by_ms must be between 1 and 86400000"

Wire-Format Mappers

The client sends snake_case JSON on the wire. Typed mappers convert between camelCase TypeScript interfaces and wire format. Use *ToWire() when building requests and *FromWire() when parsing responses.

Request mappers (camelCase → snake_case)

Mapper Converts
reservationCreateRequestToWire(req) ReservationCreateRequest → wire body
commitRequestToWire(req) CommitRequest → wire body
releaseRequestToWire(req) ReleaseRequest → wire body
reservationExtendRequestToWire(req) ReservationExtendRequest → wire body
decisionRequestToWire(req) DecisionRequest → wire body
eventCreateRequestToWire(req) EventCreateRequest → wire body
metricsToWire(metrics) CyclesMetrics → wire metrics

Response mappers (snake_case → camelCase)

Mapper Returns
reservationCreateResponseFromWire(wire) ReservationCreateResponse
commitResponseFromWire(wire) CommitResponse
releaseResponseFromWire(wire) ReleaseResponse
reservationExtendResponseFromWire(wire) ReservationExtendResponse
decisionResponseFromWire(wire) DecisionResponse
eventCreateResponseFromWire(wire) EventCreateResponse
reservationDetailFromWire(wire) ReservationDetail
reservationSummaryFromWire(wire) ReservationSummary
reservationListResponseFromWire(wire) ReservationListResponse
balanceResponseFromWire(wire) BalanceResponse
errorResponseFromWire(wire) ErrorResponse | undefined
capsFromWire(wire) Caps | undefined

Helper Functions

import {
  isAllowed,
  isDenied,
  isRetryableErrorCode,
  errorCodeFromString,
  isToolAllowed,
  isMetricsEmpty,
} from "runcycles";

// Decision helpers
isAllowed(decision);  // true for ALLOW or ALLOW_WITH_CAPS
isDenied(decision);   // true for DENY

// Error code helpers
isRetryableErrorCode(errorCode);       // true for INTERNAL_ERROR, UNKNOWN, or LIMIT_EXCEEDED (429 rate limiting)
errorCodeFromString("BUDGET_EXCEEDED"); // ErrorCode.BUDGET_EXCEEDED (or UNKNOWN for unrecognized)

// Caps helpers — check if a tool is allowed given the caps
isToolAllowed(caps, "web_search");  // checks toolAllowlist/toolDenylist

// Metrics helpers
isMetricsEmpty(metrics);  // true if all fields are undefined

Enums

import { Unit, Decision, CommitOveragePolicy, ReservationStatus, ErrorCode } from "runcycles";

// Budget units
Unit.USD_MICROCENTS  // 1 USD = 100_000_000 microcents
Unit.TOKENS
Unit.CREDITS
Unit.RISK_POINTS

// Budget decisions
Decision.ALLOW           // Full budget available
Decision.ALLOW_WITH_CAPS // Allowed with soft-landing caps
Decision.DENY            // Budget exhausted

// Overage policies (for commit and events)
CommitOveragePolicy.REJECT               // Reject if over budget
CommitOveragePolicy.ALLOW_IF_AVAILABLE   // Allow up to remaining budget
CommitOveragePolicy.ALLOW_WITH_OVERDRAFT // Allow with overdraft (creates debt)

// Reservation statuses
ReservationStatus.ACTIVE
ReservationStatus.COMMITTED
ReservationStatus.RELEASED
ReservationStatus.EXPIRED

// Error codes
ErrorCode.BUDGET_EXCEEDED
ErrorCode.OVERDRAFT_LIMIT_EXCEEDED
ErrorCode.DEBT_OUTSTANDING
ErrorCode.RESERVATION_EXPIRED
ErrorCode.RESERVATION_FINALIZED
ErrorCode.IDEMPOTENCY_MISMATCH
ErrorCode.UNIT_MISMATCH
ErrorCode.INVALID_REQUEST
ErrorCode.UNAUTHORIZED
ErrorCode.FORBIDDEN
ErrorCode.NOT_FOUND
ErrorCode.LIMIT_EXCEEDED
ErrorCode.TENANT_CLOSED
ErrorCode.INTERNAL_ERROR
ErrorCode.UNKNOWN

Nested withCycles Calls

Calling a withCycles-wrapped function from inside another withCycles-wrapped function is allowed — it will not throw an error. However, each wrapper creates an independent reservation that deducts budget separately:

const inner = withCycles({ estimate: 100, actionName: "inner" }, async () => "done");
const outer = withCycles({ estimate: 500, actionName: "outer" }, async () => {
  return await inner(); // creates a SECOND reservation — 600 total deducted, not 500
});

This means nested guards double-count budget. The outer reservation already covers the full estimated cost of the operation, so an inner reservation deducts additional budget from the same pool.

Recommended pattern: Place withCycles at the outermost entry point only. Inner functions should be plain async functions without their own guard:

const inner = async () => "done"; // no withCycles — called within a guarded operation

const outer = withCycles({ estimate: 500, actionName: "outer" }, async () => {
  return await inner(); // single reservation — 500 total
});

Examples

See the examples/ directory:

Features

  • withCycles HOF: Wraps async functions with automatic reserve/execute/commit lifecycle
  • reserveForStream: First-class streaming adapter — reserve before, heartbeat during, commit on finish
  • Programmatic client: Full control via CyclesClient with wire-format passthrough
  • Typed wire-format mappers: Convert between camelCase TypeScript and snake_case wire format
  • Automatic heartbeat: TTL extension keeps reservations alive during long operations
  • Commit retry: Failed commits are retried with exponential backoff in the background
  • Context access: getCyclesContext() provides reservation details inside guarded functions
  • Typed exceptions: BudgetExceededError, OverdraftLimitExceededError, and more
  • Zero runtime dependencies: Uses built-in fetch and AsyncLocalStorage
  • Response metadata: Access requestId, rateLimitRemaining, rateLimitReset, and cyclesTenant
  • Environment config: CyclesConfig.fromEnv() with custom prefix support
  • Dual ESM/CJS: Works with both module systems
  • Input validation: Client-side validation of TTL, amounts, subject fields, and more

Documentation

License

Apache-2.0

Releases

Packages

Used by

Contributors

Languages