Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

runcycles

Package Overview
Dependencies
Maintainers
1
Versions
6
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

runcycles

TypeScript AI agent runtime control — enforce LLM cost limits, action permissions, and audit trails for agents before execution.

latest
Source
npmnpm
Version
0.3.1
Version published
Maintainers
1
Created
Source

npm npm Downloads CI 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

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

ScenarioOutcomeDetail
Reservation deniedNeitherBudgetExceededError, OverdraftLimitExceededError, or DebtOutstandingError thrown; function never executes
dryRun: true, any decisionNeitherReturns DryRunResult or throws; no real reservation created
Function returns successfullyCommitActual amount charged; unused remainder auto-released
Function throws any errorReleaseFull reserved amount returned to budget; error re-thrown
Commit fails (5xx / network)RetryExponential backoff with configurable attempts
Commit fails (non-retryable 4xx)ReleaseReservation released after non-retryable client error
Commit gets RESERVATION_EXPIREDNeitherServer already reclaimed budget on TTL expiry
Commit gets RESERVATION_FINALIZEDNeitherAlready committed or released (idempotent replay)
Commit gets IDEMPOTENCY_MISMATCHNeitherPrevious 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). If commit() fails due to a network or server error, finalized resets to false so you can retry — but the heartbeat is not restarted (restart it manually if needed)
  • 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?

PatternUse when
withCyclesYou have an async function that returns a result — the lifecycle is fully automatic
reserveForStreamYou're streaming (e.g., LLM streaming) and usage is known only after the stream finishes
CyclesClientYou 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)
});

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):

VariableRequiredDescription
CYCLES_BASE_URLYesCycles server URL
CYCLES_API_KEYYesAPI key for authentication (see how to create one)
CYCLES_TENANTNoDefault tenant
CYCLES_WORKSPACENoDefault workspace
CYCLES_APPNoDefault app
CYCLES_WORKFLOWNoDefault workflow
CYCLES_AGENTNoDefault agent
CYCLES_TOOLSETNoDefault toolset
CYCLES_CONNECT_TIMEOUTNoConnect timeout in ms (default: 2000)
CYCLES_READ_TIMEOUTNoRead timeout in ms (default: 5000)
CYCLES_RETRY_ENABLEDNoEnable commit retry (default: true)
CYCLES_RETRY_MAX_ATTEMPTSNoMax retry attempts (default: 5)
CYCLES_RETRY_INITIAL_DELAYNoInitial retry delay in ms (default: 500)
CYCLES_RETRY_MULTIPLIERNoBackoff multiplier (default: 2.0)
CYCLES_RETRY_MAX_DELAYNoMax 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)
  useEstimateIfActualNotProvided?: boolean;  // Default: true — use estimate as actual

  // 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,
  CyclesTransportError,
} 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()) { /* ... */ }

    // 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
  } else if (err instanceof CyclesTransportError) {
    console.log("Network error:", err.message, err.cause);
  }
}

Exception hierarchy

ExceptionWhen
CyclesErrorBase for all Cycles errors
CyclesProtocolErrorServer returned a protocol-level error
BudgetExceededErrorBudget insufficient for the reservation
OverdraftLimitExceededErrorDebt exceeds the overdraft limit
DebtOutstandingErrorOutstanding debt blocks new reservations
ReservationExpiredErrorOperating on an expired reservation
ReservationFinalizedErrorOperating on an already-committed/released reservation
CyclesTransportErrorNetwork-level failure (connection, DNS, timeout)

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

MethodDescription
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/MethodDescription
reservationIdServer-assigned reservation ID
decisionBudget decision (ALLOW or ALLOW_WITH_CAPS)
capsSoft-landing caps, if any
finalizedtrue 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

When a commit fails due to a transport error or server error (5xx), the client automatically schedules background retries using exponential backoff:

  • Retries are fire-and-forget — your guarded function returns immediately; the commit is retried in the background
  • Backoff formula: min(initialDelay * multiplier^attempt, maxDelay) — defaults to 500ms, 1s, 2s, 4s, 8s
  • Non-retryable errors (4xx client errors) stop retries immediately
  • Already-finalized reservations (RESERVATION_FINALIZED, RESERVATION_EXPIRED) are accepted silently
  • Retries only apply to commits from withCycles — the streaming adapter and programmatic client do not auto-retry

Configure via CyclesConfig:

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

Heartbeat

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

  • Interval: max(ttlMs / 2, 1000ms) — e.g., a 60s TTL heartbeats every 30s
  • Extension amount: equals the full ttlMs each time
  • Best-effort: heartbeat failures are silently ignored
  • Auto-stop: the heartbeat stops when the reservation is committed, released, or disposed

Validation

The client validates inputs before sending requests:

FieldConstraintError
subjectAt least one of: tenant, workspace, app, workflow, agent, toolset"Subject must have at least one standard field (tenant, workspace, app, workflow, agent, or toolset)"
estimateMust be >= 0"estimate must be non-negative"
ttlMs1,000 – 86,400,000 ms (1s – 24h)"ttl_ms must be between 1000 and 86400000"
gracePeriodMs0 – 60,000 ms (0 – 60s)"grace_period_ms must be between 0 and 60000"
extendByMs1 – 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)

MapperConverts
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)

MapperReturns
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 or UNKNOWN
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.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

Keywords

ai-agent

FAQs

Package last updated on 07 May 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts