# Cycles — Complete Documentation
> Runtime authority for autonomous agents. Keep agents within approved spend, risk, and action limits. Open protocol, multi-language SDKs, Apache 2.0.
This file contains the full text of all Cycles documentation, optimized for LLM ingestion. For a lightweight navigation index, see llms.txt.
---
# Quickstart
# Architecture Overview: How Cycles Fits Together
Cycles is a runtime authority for autonomous agents. It sits between your application and the actions that cost money or carry risk.
This page describes the components, how they interact, and where each piece runs.
::: tip Prerequisites
This is a reference page. If you haven't set up Cycles yet, start with the [End-to-End Tutorial](/quickstart/end-to-end-tutorial) or [Deploy the Full Stack](/quickstart/deploying-the-full-cycles-stack).
:::
## System overview
Your application talks to the **Cycles Server** (port 7878) at runtime. The **Cycles Admin Server** (port 7979) is the management plane where you create tenants, generate API keys, and configure budget ledgers. The **Cycles Events Service** is an outbound worker that delivers webhook notifications asynchronously and, when CyclesEvidence is enabled, signs evidence envelopes; its app port 7980 and management port 9980 should stay internal. All three services share the same Redis instance.
::: info Independent release cadences
Runtime, admin, events, and dashboard images ship patch releases independently. Current tagged versions are maintained in the [release matrix](/changelog#current-versions); use that matrix instead of assuming the four repositories share one patch number. Older admin servers that predate newer query parameters (e.g., `sort_by`, `search`) ignore them rather than erroring — the APIs follow an additive-parameter guarantee.
:::
## Components
### Cycles Protocol
The protocol specification defines the API contract. It is a language-agnostic OpenAPI 3.1 spec that any client or server can implement.
The protocol defines:
- Runtime endpoints for decisions, reservations, balances, event ingest, evidence retrieval, and signer JWKS publication
- The Subject hierarchy (tenant, workspace, app, workflow, agent, toolset)
- The reserve → execute → commit lifecycle
- Error codes and their semantics
- Idempotency guarantees
- Scope derivation rules
The spec lives at [cycles-protocol](https://github.com/runcycles/cycles-protocol).
### Cycles Server
The reference server implementation. It is a Spring Boot 3.5 application backed by Redis 7+.
**What it does:**
- Accepts HTTP requests from clients
- Validates API keys and enforces tenant isolation
- Executes atomic budget operations via Redis Lua scripts
- Maintains budget state (allocated, spent, reserved, debt)
- Runs a background expiry sweep to clean up abandoned reservations
- Computes CyclesEvidence content hashes synchronously, returns `cycles_evidence` refs when configured, and serves signed envelopes plus public signer JWKS
**Modules:**
| Module | Purpose |
|---|---|
| `cycles-protocol-service-api` | REST controllers, security filters, exception handling |
| `cycles-protocol-service-data` | Redis repository, Lua scripts, scope derivation, expiry service |
| `cycles-protocol-service-model` | Shared DTOs and enums |
**Why Redis and Lua:**
Budget enforcement under concurrency requires atomicity. A reservation must check and update multiple scope counters in a single operation. Redis Lua scripts execute atomically on the server, ensuring no race conditions between concurrent reservations.
Six Lua scripts handle the core operations:
| Script | Operation |
|---|---|
| `reserve.lua` | Check budgets across all scopes, reserve atomically |
| `commit.lua` | Record actual spend, release remainder, handle overage |
| `release.lua` | Return reserved budget to pool |
| `extend.lua` | Extend reservation TTL |
| `event.lua` | Record direct debit without reservation |
| `expire.lua` | Mark expired reservations and release their budget |
### Cycles Admin Server
The management plane for Cycles. It runs as a separate Spring Boot 3.5 service on port 7979 and shares the same Redis instance as the Cycles Server.
The optional [Cycles Admin Dashboard](/quickstart/deploying-the-cycles-dashboard) (Vue 3 SPA) sits in front of this server and exposes its operations as a web UI — useful for day-two ops without crafting curl commands.
**What it does:**
- Manages tenants (create, list, update, suspend, close)
- Creates and revokes API keys with granular permissions
- Creates budget ledgers and handles funding operations (credit, debit, reset, reset_spent, repay debt)
- Defines policies (caps, rate limits, TTL overrides) matched by scope patterns — **stored for future runtime enforcement; not yet evaluated by the Cycles Server in v0**
- Validates API keys (used by the Cycles Server for authentication)
- Maintains an audit log of all administrative operations
**Modules:**
| Module | Purpose |
|---|---|
| `cycles-admin-service-api` | REST controllers, auth interceptor, Spring Boot app |
| `cycles-admin-service-data` | Redis repositories, key service |
| `cycles-admin-service-model` | Shared domain models and DTOs |
**Authentication:** Cycles uses two auth schemes depending on the endpoint. `X-Admin-API-Key` is the bootstrap/operator key for tenant and key management, audit, admin-only budget operations, and a small runtime admin-on-behalf-of reservation surface. `X-Cycles-API-Key` is tenant-scoped and carries explicit permissions for budgets, policies, reservations, balances, events, and tenant self-service webhooks.
#### `X-Admin-API-Key` — bootstrap / system administration
Set via the `ADMIN_API_KEY` environment variable. Not scoped to any tenant.
| Endpoint | Method | Purpose |
|---|---|---|
| `/v1/admin/tenants/*` | POST, GET, PATCH | Create, list, update, suspend tenants |
| `/v1/admin/api-keys/*` | POST, GET, DELETE | Create, list, revoke API keys |
| `/v1/auth/validate` | POST | Validate an API key |
| `/v1/admin/audit/logs` | GET | Query audit logs |
| `/v1/admin/budgets` | PATCH | Update budget settings (overage policy, overdraft limit) — admin key only |
| `/v1/admin/budgets/freeze`, `/v1/admin/budgets/unfreeze` | POST | Admin-only budget state changes |
| `/v1/reservations`, `/v1/reservations/{id}`, `/v1/reservations/{id}/release` | GET / POST | Runtime admin-on-behalf-of inspection and force release |
#### `X-Cycles-API-Key` — tenant-scoped operations
Requires a key created via the admin API with the appropriate [permissions](/how-to/api-key-management-in-cycles#available-permissions).
| Endpoint | Method | Required Permission |
|---|---|---|
| `/v1/admin/budgets` | POST | `budgets:write` |
| `/v1/admin/budgets` | GET | `budgets:read` |
| `/v1/admin/budgets/fund` | POST | `budgets:write` |
| `/v1/admin/policies` | POST, PATCH | `policies:write` |
| `/v1/admin/policies` | GET | `policies:read` |
| `/v1/balances` | GET | `balances:read` |
| `/v1/reservations` | GET | `reservations:list` |
| `/v1/reservations` | POST | `reservations:create` |
| `/v1/reservations/{id}/commit` | POST | `reservations:commit` |
| `/v1/reservations/{id}/release` | POST | `reservations:release` |
| `/v1/reservations/{id}/extend` | POST | `reservations:extend` |
| `/v1/decide` | POST | *(valid key only)* |
| `/v1/events` | POST | *(valid key only)* |
PATCH `/v1/admin/budgets` (budget settings) is `X-Admin-API-Key`-only — it is not available to tenant keys. The legacy `admin:write` / `admin:read` permissions act as wildcards that satisfy any `*:write` / `*:read` requirement, but new keys should carry the granular permissions (`budgets:write`, `policies:write`, etc.) instead.
Note that the admin server enforces these per-endpoint permissions on the governance plane. The reference runtime server (port 7878) does **not** enforce per-endpoint permissions on the runtime plane — it checks key validity and tenant match only, so the `reservations:*` permission strings on runtime endpoints document the spec's intent rather than reference-server behavior.
::: tip Which header do I use?
If the endpoint manages **identity**, fleet-level audit, admin-only budget state, or operator force-release → `X-Admin-API-Key`.
If the endpoint is a tenant-scoped budget, policy, runtime, balance, or event call → `X-Cycles-API-Key`.
:::
#### Key provisioning
The two headers represent different keys with different lifecycles:
1. **`X-Admin-API-Key`** is a static secret you choose at deploy time. Set it as the `ADMIN_API_KEY` environment variable when starting the admin server. There is no API to create or rotate it — you manage it like any infrastructure secret (secrets manager, env vars, etc.).
2. **`X-Cycles-API-Key`** keys are created dynamically via `POST /v1/admin/api-keys` (authenticated with the admin key). Each key is scoped to one tenant and carries explicit permissions. The key secret (e.g., `cyc_live_abc123...`) is returned once at creation time.
**Bootstrap order:** deploy server with admin key → create tenant → create API key → use API key for budgets and runtime operations. See [Deploying the Full Cycles Stack](/quickstart/deploying-the-full-cycles-stack) for the step-by-step walkthrough.
**Why a separate server:**
Separating the management plane from the runtime enforcement plane lets you:
- Run the admin server in a restricted network (internal only) while the Cycles Server is accessible to applications
- Scale the enforcement server independently from the admin server
- Apply different access controls to management vs runtime operations
See the [Admin API reference](/admin-api/) for the full API, or the [governance spec](https://github.com/runcycles/cycles-protocol/blob/main/cycles-governance-admin-v0.1.25.yaml) for the authoritative OpenAPI definition.
### Cycles Events Service
The async webhook delivery and evidence signing service. It runs as a separate Spring Boot 3.5 service with an internal app port (7980) and management/actuator port (9980), and shares the same Redis instance.
**What it does:**
- Consumes delivery jobs from a Redis queue (`dispatch:pending`) via BLMOVE — a reliable-queue pattern that parks each claimed job on `dispatch:processing` until acknowledged, recovering orphans idle longer than `DISPATCH_PROCESSING_RECOVERY_IDLE_MS` (default 180000 ms)
- Delivers events to webhook endpoints via HTTP POST with HMAC-SHA256 signatures
- Retries failed deliveries with exponential backoff (configurable: default 5 retries, 1s–60s delay)
- Auto-disables subscriptions after consecutive failures (default threshold: 10)
- Expires stale deliveries after configurable max age (default: 24h)
- Cleans up expired ZSET index entries hourly
- Builds and Ed25519-signs CyclesEvidence envelopes when `EVIDENCE_SERVER_ID` and the signing key are configured
- Stores signed evidence envelopes content-addressed for the runtime server to serve at `GET /v1/evidence/{id}`
**Why a separate service:**
| Concern | Admin Server | Events Service |
|---------|-------------|----------------|
| Workload | Synchronous CRUD, operator-facing | Asynchronous delivery and signing, variable latency |
| Scaling | Scale with admin traffic | Scale with webhook volume |
| Failure isolation | Admin stays responsive during delivery backlog | Delivery retries don't block admin API |
| Concurrency | Single instance | Multiple instances safe (the BLMOVE claim is atomic) |
**Optional:** If the events service is not deployed, admin and runtime servers operate normally. Webhook events and deliveries accumulate in Redis (bounded by TTL) and are processed when the events service starts. If CyclesEvidence is configured on the runtime server but the events service is down, responses may carry `cycles_evidence` refs while `GET /v1/evidence/{id}` returns transient `404` until the signer catches up.
See [Deploying the Events Service](/quickstart/deploying-the-events-service) for setup, [Webhook Event Delivery Protocol](/protocol/webhook-event-delivery-protocol) for webhook delivery, and [CyclesEvidence Envelopes](/protocol/cycles-evidence-envelopes-in-cycles) for evidence signing and verification.
### Cycles MCP Server
A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes Cycles runtime authority as MCP tools. MCP-compatible AI hosts (Claude Desktop, Claude Code, Cursor, Windsurf) discover and call these tools automatically.
**What it does:**
- Exposes 9 MCP tools covering the full Cycles protocol (reserve, commit, release, extend, decide, balance, events, reservations)
- Ships 3 built-in prompts for integration code generation, budget debugging, and strategy design
- Provides resources for inspecting balances and reservation state
- Wraps the `runcycles` TypeScript client internally — talks to the Cycles Server via HTTP
**When to use it:**
Use the MCP server when your agent host supports MCP and you want to expose Cycles tools without an SDK integration. Adding it to the tool configuration is sufficient for discovery, not for hard enforcement: the standalone server does not wrap the host's other actions. Add **Cycles Budget Guard for Claude Code** or a mandatory handler, gateway, harness, or service boundary when the action must not bypass Cycles. See [Getting Started with the MCP Server](/quickstart/getting-started-with-the-mcp-server).
### Cycles Spring Boot Starter
A client library that integrates Cycles into Spring Boot applications. It provides two usage modes:
1. **Declarative** — The `@Cycles` annotation wraps methods in a reserve → execute → commit lifecycle automatically via Spring AOP
2. **Programmatic** — The `CyclesClient` interface can be injected and used directly for fine-grained control
**Key components:**
| Component | Purpose |
|---|---|
| `@Cycles` annotation | Declarative budget enforcement on methods |
| `CyclesAspect` | AOP interceptor that drives the lifecycle |
| `CyclesLifecycleService` | Orchestrates reserve/execute/commit/release |
| `CyclesClient` / `DefaultCyclesClient` | HTTP client using Spring WebClient |
| `CyclesContextHolder` | ThreadLocal access to reservation state mid-execution |
| `CyclesExpressionEvaluator` | SpEL evaluation for dynamic estimates and actuals |
| `CyclesFieldResolver` | Interface for dynamic Subject field resolution |
| `CommitRetryEngine` | Same-key settlement retry, durable journal replay, and expired-commit event fallback |
| `CyclesProperties` | Spring Boot configuration properties |
## Request flow
Here is what happens when an `@Cycles`-annotated method is called:
### 1. Estimate evaluation
The SpEL expression in the annotation is evaluated against method parameters to produce a numeric estimate.
### 2. Reservation request
The starter sends `POST /v1/reservations` to the Cycles server with the Subject, Action, estimate, TTL, and overage policy.
### 3. Atomic budget check (server side)
The server derives all affected scopes from the Subject, then executes `reserve.lua`. The Lua script:
- Checks each scope has sufficient remaining budget (`allocated - spent - reserved - debt >= estimate`)
- Checks no scope has outstanding debt or is over-limit
- If all checks pass, atomically increments the `reserved` counter on every scope
- Stores the reservation record with its TTL
### 4. Decision returned
A successful live reservation returns `ALLOW` or `ALLOW_WITH_CAPS`. If a live reservation is denied, the server returns a protocol error such as `409 BUDGET_EXCEEDED`; `decision: DENY` is reserved for `/v1/decide` and dry-run reservation evaluations.
### 5. Method execution
If allowed, the starter runs the annotated method. During execution:
- A heartbeat thread periodically extends the reservation TTL
- The method can access `CyclesContextHolder` to read caps or set metrics
### 6. Commit
After the method returns, the starter evaluates the `actual` expression, durably journals the settlement, and sends `POST /v1/reservations/{id}/commit`. The server executes `commit.lua` to record actual spend and release the unused remainder. Ambiguous outcomes retain the same-key record for replay; an expired commit switches to a direct event.
### 7. Error path
If the method throws, the starter sends `POST /v1/reservations/{id}/release` to return all reserved budget to the pool.
## Data model
All budget state lives in Redis. The key concepts:
### Scopes
A scope is a budgeting boundary derived from the Subject hierarchy. A single reservation may affect multiple scopes. For example, a reservation with `tenant=acme, workspace=prod, app=chatbot` affects three scopes:
- `tenant:acme`
- `tenant:acme/workspace:prod`
- `tenant:acme/workspace:prod/app:chatbot`
### Balances
Each scope tracks:
| Field | Meaning |
|---|---|
| `allocated` | Total budget assigned to this scope |
| `spent` | Committed actual usage |
| `reserved` | Currently held by active reservations |
| `remaining` | `allocated - spent - reserved - debt` |
| `debt` | Negative balance from overdraft commits |
| `overdraft_limit` | Maximum allowed debt |
| `is_over_limit` | Whether `debt > overdraft_limit` |
### Reservations
Each reservation is stored with:
- Unique ID
- Subject and action metadata
- Reserved amount and unit
- Status (ACTIVE, COMMITTED, RELEASED, EXPIRED)
- TTL and grace period timestamps
- Idempotency key and payload hash
## Authentication
Budget and reservation requests authenticate via the `X-Cycles-API-Key` header. Each API key is associated with a tenant. The server enforces that `subject.tenant` matches the key's tenant — a key for tenant A cannot create reservations for tenant B.
Public runtime endpoints are intentionally narrow: the `/actuator/health/liveness` and `/actuator/health/readiness` probes, `GET /v1/evidence/{id}` (a content-addressed capability URL), and `GET /v1/.well-known/cycles-jwks.json` (public verification keys only). Since cycles-server 0.1.25.45 the aggregate `/actuator/health`, `/actuator/prometheus`, and the OpenAPI/Swagger docs paths require the `X-Admin-API-Key` header. Evidence retrieval does not expose ledger state; the unguessable `evidence_id` is the lookup capability.
Since 0.1.25.46 the public evidence and JWKS endpoints are rate-limited per client IP — 300 requests/minute by default (on by default; `cycles.public-rate-limit.*` properties). Exceeding the window returns `429` with `error=LIMIT_EXCEEDED` and a `Retry-After` header.
## Deployment topology
A typical deployment:
Multiple Cycles server instances can run behind a load balancer. All state is in Redis, so the server is stateless. The admin server is typically on an internal network, accessible only to operators and CI/CD pipelines. The events service is optional — if deployed, it consumes delivery jobs from Redis, delivers webhooks with HMAC-SHA256 signatures, and signs CyclesEvidence envelopes when the shared evidence identity is configured.
Non-Spring clients (Python, TypeScript/Node.js, Go) can use the protocol directly via HTTP — the client libraries are convenience layers, not a requirement. MCP-compatible agents (Claude Desktop, Claude Code, Cursor, Windsurf) can use the Cycles MCP Server for a zero-code integration path.
## Next steps
- [Tenants, Scopes, and Budgets](/how-to/understanding-tenants-scopes-and-budgets-in-cycles) — how tenants, scopes, and budgets work together as a unified model
- [Deploying the Full Cycles Stack](/quickstart/deploying-the-full-cycles-stack) — zero to working deployment with all components
- [Self-Hosting the Cycles Server](/quickstart/self-hosting-the-cycles-server) — server-specific configuration and deployment
- [API Reference](/api/) — interactive endpoint documentation
- [Getting Started with the MCP Server](/quickstart/getting-started-with-the-mcp-server) — add runtime authority to Claude Desktop, Claude Code, Cursor, or Windsurf
- [Getting Started with the Python Client](/quickstart/getting-started-with-the-python-client) — integrate with your Python app
- [Getting Started with the TypeScript Client](/quickstart/getting-started-with-the-typescript-client) — integrate with your TypeScript/Node.js app
- [Getting Started with the Spring Boot Starter](/quickstart/getting-started-with-the-cycles-spring-boot-starter) — integrate with your Spring app
# Deploy the Cycles Admin Dashboard
The Cycles Admin Dashboard is a Vue 3 single-page app that sits in front of [`cycles-server-admin`](https://github.com/runcycles/cycles-server-admin) and provides an operations-oriented UI for tenants, budgets, webhooks, and incident response. It's a thin client — all state lives in the admin server; the dashboard just visualises it and calls admin API endpoints on your behalf.
End-to-end walkthrough of the main operator flows
::: info When to deploy the dashboard
If you only need SDK integration (Python, TypeScript, Spring, Rust, MCP), you can skip this page — the dashboard is optional. Deploy it when you want a UI for day-two operations: investigating events, freezing a runaway budget, rotating API keys, replaying missed webhooks, or force-releasing hung reservations during an incident.
:::
## What you get
A single pane for all operator tasks, capability-gated by the admin key:
| Page | Purpose |
|---|---|
| **Overview** | Single-request aggregated health dashboard — entity counts, top offenders, failing webhooks, over-limit scopes |
| **Tenants** | Tenant list + detail with budgets, API keys, and policies tabs |
| **Budgets** | Tenant-scoped budget list with utilization/debt bars; inline `RESET` and `RESET_SPENT` |
| **Events** | Correlation-first investigation tool with expandable detail rows |
| **API Keys** | Cross-tenant key list with masked IDs, permissions, status filters |
| **Webhooks** | Subscription health (green/yellow/red) + delivery history, replay, test |
| **Reservations** | Hung-reservation force-release during incident response (runtime-plane admin-on-behalf-of), with "View evidence" links when the runtime server emits evidence refs |
| **Evidence** | Retrieve and inspect a signed evidence envelope by id; resolve the signer against the runtime server's published JWK Set |
| **Audit** | Compliance query tool with CSV/JSON export |
Incident-response actions (freeze budget, suspend tenant, revoke API key, pause webhook, force-release reservation, emergency tenant-wide freeze) are one-click with confirmation and blast-radius summaries.
### Power-user features (v0.1.25.24+)
- **Command palette** — press `Cmd+K` on macOS or `Ctrl+K` on Linux/Windows to jump to any tenant, budget, webhook, or API key by ID or name. The palette also exposes the common incident actions without navigating.
- **Bulk action lanes** — the Tenants and Webhooks pages expose a filter-then-bulk workflow that maps onto the admin bulk endpoints (`POST /v1/admin/tenants/bulk-action`, `POST /v1/admin/webhooks/bulk-action`). The UI surfaces the `expected_count` safety gate, the 500-match `LIMIT_EXCEEDED` ceiling, and per-row succeeded/failed/skipped results.
- **Tenant hierarchy breadcrumbs** — detail pages show `tenant → workspace → app → workflow` so operators can navigate back up the scope path without losing context.
- **RESET_SPENT inline funding** — the Budgets page exposes `RESET_SPENT` alongside `RESET`, clearing `spent` for a new billing period without disturbing `reserved`/`debt`. Matches the server-side funding operation added in `cycles-server-admin` v0.1.25.18.
## Architecture
The dashboard is a static SPA served by nginx. It talks to **two backends** — the governance plane (`cycles-server-admin`) for tenants, budgets, policies, webhooks, events, and audit; and the runtime plane (`cycles-server`) for reservation force-release during incident response. Both are reverse-proxied through the dashboard's own nginx so the browser sees everything as same-origin and CORS is not involved in a standard production deployment.
The default path (left branch) carries the governance/admin pages. The split path (right branch) carries runtime-plane Reservations and Evidence calls, including force-release, evidence lookup, and signer JWKS resolution.
The nginx routing split in `default.conf.template` (the TLS variant is `nginx-ssl.conf.example`):
| Request path | Upstream | Used by |
|---|---|---|
| `/v1/reservations*`, `/v1/evidence*`, `/v1/.well-known/cycles-jwks.json` | `cycles-server:7878` | Reservations and Evidence pages — force-release, envelope lookup, signer-key resolution |
| `/v1/*` (everything else) | `cycles-admin:7979` | All other dashboard pages |
Both backends authenticate the same `X-Admin-API-Key` header. On the runtime plane, force-release is an admin-on-behalf-of call — the runtime server validates the admin key and records the actor in the audit trail.
## Quick start (development)
For local development against a running admin server:
```bash
git clone https://github.com/runcycles/cycles-dashboard.git
cd cycles-dashboard
npm install
npm run dev
```
Dashboard opens at `http://localhost:5173`. The Vite dev server mirrors the production routing split:
- `/v1/reservations*`, `/v1/evidence*`, `/v1/.well-known/cycles-jwks.json` → `localhost:7878` (`cycles-server` — runtime plane)
- `/v1/*` (everything else) → `localhost:7979` (`cycles-admin` — governance plane)
**You need both backends running.** See [Deploy the Full Stack](/quickstart/deploying-the-full-cycles-stack) to bring them up — or `cd ../cycles-server-admin && ADMIN_API_KEY=your-key docker compose up -d` if you only want admin + Redis and plan to run `cycles-server` separately. Log in with the same `ADMIN_API_KEY` you set on the servers (both admin and runtime must share the same key for force-release to work).
::: tip CORS for dev mode
If the admin or runtime server rejects your dev browser with a CORS error, set `DASHBOARD_CORS_ORIGIN=http://localhost:5173` on **both** containers. (In production with the nginx reverse proxy, CORS is not needed — the dashboard's nginx makes every backend call same-origin.)
:::
## Production (Docker + Caddy)
Recommended production setup uses Caddy for automatic HTTPS. Only ports 443 and 80 are exposed; admin server and Redis stay on the internal Docker network.
```yaml
# docker-compose.prod.yml
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "443:443"
- "80:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
depends_on:
- dashboard
networks:
- cycles
dashboard:
image: ghcr.io/runcycles/cycles-dashboard:0.1.25.85
restart: unless-stopped
# No exposed ports — only reachable through Caddy.
environment:
ADMIN_UPSTREAM: ${ADMIN_UPSTREAM:-http://cycles-admin:7979}
RUNTIME_UPSTREAM: ${RUNTIME_UPSTREAM:-http://cycles-server:7878}
depends_on:
cycles-admin:
condition: service_healthy
cycles-server:
condition: service_healthy
networks:
- cycles
# Runtime plane — reservation force-release, evidence lookup, and signer JWKS
# go here via /v1/reservations*, /v1/evidence*, and
# /v1/.well-known/cycles-jwks.json.
# Its ADMIN_API_KEY must match cycles-admin's so admin-on-behalf-of calls
# authenticate on both sides.
cycles-server:
image: ghcr.io/runcycles/cycles-server:0.1.25.59
restart: unless-stopped
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD must be set}
ADMIN_API_KEY: ${ADMIN_API_KEY:?ADMIN_API_KEY must be set}
WEBHOOK_SECRET_ENCRYPTION_KEY: ${WEBHOOK_SECRET_ENCRYPTION_KEY:?WEBHOOK_SECRET_ENCRYPTION_KEY must be set}
DASHBOARD_CORS_ORIGIN: ${DASHBOARD_ORIGIN:-https://admin.example.com}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:7878/actuator/health/readiness"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
depends_on:
redis:
condition: service_healthy
networks:
- cycles
# Governance plane — tenants, budgets, policies, webhooks, events, audit.
cycles-admin:
image: ghcr.io/runcycles/cycles-server-admin:0.1.25.55
restart: unless-stopped
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD must be set}
ADMIN_API_KEY: ${ADMIN_API_KEY:?ADMIN_API_KEY must be set}
WEBHOOK_SECRET_ENCRYPTION_KEY: ${WEBHOOK_SECRET_ENCRYPTION_KEY:?WEBHOOK_SECRET_ENCRYPTION_KEY must be set}
WEBHOOK_SECRET_ALLOW_PLAINTEXT: "false"
DASHBOARD_CORS_ORIGIN: ${DASHBOARD_ORIGIN:-https://admin.example.com}
EVENT_TTL_DAYS: ${EVENT_TTL_DAYS:-90}
DELIVERY_TTL_DAYS: ${DELIVERY_TTL_DAYS:-14}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:7979/actuator/health/readiness"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
depends_on:
redis:
condition: service_healthy
networks:
- cycles
# Async webhook delivery and optional CyclesEvidence signing.
cycles-events:
image: ghcr.io/runcycles/cycles-server-events:0.1.25.25
restart: unless-stopped
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD must be set}
WEBHOOK_SECRET_ENCRYPTION_KEY: ${WEBHOOK_SECRET_ENCRYPTION_KEY:?WEBHOOK_SECRET_ENCRYPTION_KEY must be set}
# Evidence signing is off unless these are set consistently with cycles-server:
# EVIDENCE_SERVER_ID, EVIDENCE_SIGNING_SIGNER_DID,
# EVIDENCE_SIGNING_PRIVATE_KEY_HEX.
depends_on:
redis:
condition: service_healthy
networks:
- cycles
redis:
image: redis:7-alpine
restart: unless-stopped
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD must be set}
command: ["sh", "-c", "redis-server --appendonly yes --requirepass \"$${REDIS_PASSWORD}\""]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- cycles
volumes:
redis-data:
caddy-data:
networks:
cycles:
```
::: warning Both backends required
The dashboard's nginx routes `/v1/reservations*`, `/v1/evidence*`, and `/v1/.well-known/cycles-jwks.json` to `cycles-server:7878`. If you omit `cycles-server`, every governance page works but Reservations and Evidence fail with a 502. The `ADMIN_API_KEY` **must be the same** on both `cycles-admin` and `cycles-server` — admin-on-behalf-of force-release calls authenticate on both sides. Signed evidence also requires `cycles-server-events` to run with the matching evidence private key.
:::
```
# Caddyfile
admin.example.com {
reverse_proxy dashboard:80
}
```
Deploy:
```bash
# Generate the secrets into shell variables first. Do NOT write the
# $(openssl ...) calls inside a quoted heredoc — the command substitution
# would never run, and every deployment would use the literal string
# "$(openssl rand -base64 32)" as its key.
ADMIN_API_KEY="$(openssl rand -base64 32)"
REDIS_PASSWORD="$(openssl rand -base64 32)"
WEBHOOK_SECRET_ENCRYPTION_KEY="$(openssl rand -base64 32)"
cat > .env <TL;DR — Full quickstart in 60 seconds
::: warning Production security
The quickstart examples below use empty `REDIS_PASSWORD` and a weak `ADMIN_API_KEY` for fast local development. **For production**, generate strong secrets before deploying:
```bash
export REDIS_PASSWORD=$(openssl rand -base64 32)
export ADMIN_API_KEY=$(openssl rand -base64 32)
export WEBHOOK_SECRET_ENCRYPTION_KEY=$(openssl rand -base64 32)
```
Store these in a secrets manager, not in docker-compose files. Bind the Admin Server to internal network only (`127.0.0.1:7979:7979`). See [Security Hardening](/how-to/security-hardening) for the full checklist.
:::
If you have Docker running and just want to try Cycles immediately, copy-paste this entire block:
```bash
# 1. Create docker-compose.yml and start the stack
cat > docker-compose.yml <<'COMPOSE'
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes: ["redis-data:/data"]
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
cycles-admin:
image: ghcr.io/runcycles/cycles-server-admin:0.1.25.55
ports: ["7979:7979"]
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ""
ADMIN_API_KEY: admin-bootstrap-key
WEBHOOK_SECRET_ENCRYPTION_KEY: "${WEBHOOK_SECRET_ENCRYPTION_KEY:?WEBHOOK_SECRET_ENCRYPTION_KEY must be set}"
WEBHOOK_SECRET_ALLOW_PLAINTEXT: "false"
DASHBOARD_CORS_ORIGIN: "${DASHBOARD_CORS_ORIGIN:-http://localhost:5173}"
depends_on:
redis: { condition: service_healthy }
cycles-server:
image: ghcr.io/runcycles/cycles-server:0.1.25.59
ports: ["7878:7878"]
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ""
# Same value as the admin server's ADMIN_API_KEY. Without it, the
# protected operational endpoints (aggregate health, Prometheus,
# API docs) and the admin-on-behalf-of paths return 500
# "server misconfiguration".
ADMIN_API_KEY: admin-bootstrap-key
WEBHOOK_SECRET_ENCRYPTION_KEY: "${WEBHOOK_SECRET_ENCRYPTION_KEY:?WEBHOOK_SECRET_ENCRYPTION_KEY must be set}"
DASHBOARD_CORS_ORIGIN: "${DASHBOARD_CORS_ORIGIN:-http://localhost:5173}"
depends_on:
redis: { condition: service_healthy }
# Optional: webhook event delivery and evidence signing worker
cycles-events:
image: ghcr.io/runcycles/cycles-server-events:0.1.25.25
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ""
WEBHOOK_SECRET_ENCRYPTION_KEY: "${WEBHOOK_SECRET_ENCRYPTION_KEY:?WEBHOOK_SECRET_ENCRYPTION_KEY must be set}"
WEBHOOK_SECRET_ALLOW_PLAINTEXT: "false"
depends_on:
redis: { condition: service_healthy }
volumes:
redis-data:
COMPOSE
# Generate the shared encryption key required by admin and events
export WEBHOOK_SECRET_ENCRYPTION_KEY=$(openssl rand -base64 32)
docker compose up -d
# 2. Wait for services to be ready (readiness probes are public;
# aggregate /actuator/health requires X-Admin-API-Key since cycles-server 0.1.25.45)
echo "Waiting for services..."
until curl -sf http://localhost:7878/actuator/health/readiness > /dev/null 2>&1; do sleep 1; done
until curl -sf http://localhost:7979/actuator/health/readiness > /dev/null 2>&1; do sleep 1; done
echo "Services are up."
# 3. Create tenant
curl -s -X POST http://localhost:7979/v1/admin/tenants \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: admin-bootstrap-key" \
-d '{"tenant_id": "acme-corp", "name": "Acme Corporation"}' | jq .
# 4. Create API key and capture it
API_KEY=$(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": "quickstart-key",
"permissions": ["reservations:create","reservations:commit","reservations:release","reservations:extend","reservations:list","balances:read","budgets:write"]
}' | jq -r '.key_secret')
echo "API Key: $API_KEY"
# 5. Create a budget ($1.00 = 100,000,000 microcents)
curl -s -X POST http://localhost:7979/v1/admin/budgets \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $API_KEY" \
-d '{"scope": "tenant:acme-corp", "unit": "USD_MICROCENTS", "allocated": {"amount": 100000000, "unit": "USD_MICROCENTS"}}' | jq .
# 6. Test: reserve → commit → check balance
RESERVATION_ID=$(curl -s -X POST http://localhost:7878/v1/reservations \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $API_KEY" \
-d '{
"idempotency_key": "qs-reserve-001",
"subject": {"tenant": "acme-corp"},
"action": {"kind": "llm.completion", "name": "openai:gpt-4o"},
"estimate": {"amount": 500000, "unit": "USD_MICROCENTS"},
"ttl_ms": 30000
}' | jq -r '.reservation_id')
echo "Reserved: $RESERVATION_ID"
curl -s -X POST "http://localhost:7878/v1/reservations/$RESERVATION_ID/commit" \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $API_KEY" \
-d '{"idempotency_key": "qs-commit-001", "actual": {"amount": 350000, "unit": "USD_MICROCENTS"}}' | jq .
curl -s "http://localhost:7878/v1/balances?tenant=acme-corp" \
-H "X-Cycles-API-Key: $API_KEY" | jq .
echo ""
echo "Done! Your Cycles stack is running."
echo " Runtime server: http://localhost:7878"
echo " Admin server: http://localhost:7979"
echo " API key: $API_KEY"
# Swagger UI: the runtime server's /swagger-ui.html requires the
# X-Admin-API-Key header since 0.1.25.45; the admin server's is disabled
# by default (enable with API_DOCS_ENABLED=true and SWAGGER_ENABLED=true).
```
## What you are deploying
A complete Cycles deployment has four components that share a single Redis instance:
| Component | Purpose | Port / access |
|---|---|---|
| **Redis 7+** | Stores all budget state, reservations, and tenant data | 6379 |
| **Cycles Admin Server** | Create tenants, API keys, and budget ledgers. Management plane. | 7979 |
| **Cycles Server** | Runtime budget enforcement. Your app talks to this. | 7878 |
| **Cycles Events Service** | Async webhook delivery with HMAC signing and optional CyclesEvidence signing. | No public inbound; app 7980 and management 9980 stay internal |
Your application only talks to the **Cycles Server** (port 7878). You use the **Admin Server** (port 7979) to set up tenants, keys, and budgets before your app starts enforcing. The **Events Service** is optional and outbound-only for normal operation — it delivers webhook notifications asynchronously and, when evidence is configured, signs CyclesEvidence envelopes for the runtime server to serve. See [Deploying the Events Service](/quickstart/deploying-the-events-service).
::: info Optional: deploy the admin dashboard
For a web UI on top of this stack — operator workflows for tenants, budgets, webhooks, events, audit, and incident response (freeze, suspend, force-release) — also deploy the [Cycles Admin Dashboard](/quickstart/deploying-the-cycles-dashboard). It's a Vue 3 SPA that proxies through to the admin server (and to the runtime server for force-release). Skip if you only need SDK integration.
:::
## Prerequisites
- **Docker** and **Docker Compose** (for the quick path — no Java needed), or
- **Java 21+** and **Maven 3.9+** (for running from source without Docker)
- **Redis 7+** (if not using Docker)
Verify Docker is ready:
```bash
docker --version # Docker 20+ required
docker compose version # Docker Compose v2+ required
```
If `docker compose` fails, you may need to install the Docker Compose plugin or use the standalone `docker-compose` binary.
## Step 1: Start the infrastructure
### Option A: Docker Compose from GHCR images (recommended for end users)
The easiest way to deploy is using pre-built images from GitHub Container Registry. No Java or Maven required — Docker pulls the images automatically.
Create a `docker-compose.yml`:
```yaml
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
cycles-admin:
image: ghcr.io/runcycles/cycles-server-admin:0.1.25.55
ports:
- "7979:7979"
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ""
ADMIN_API_KEY: ${ADMIN_API_KEY:-admin-bootstrap-key}
WEBHOOK_SECRET_ENCRYPTION_KEY: ${WEBHOOK_SECRET_ENCRYPTION_KEY:?WEBHOOK_SECRET_ENCRYPTION_KEY must be set}
WEBHOOK_SECRET_ALLOW_PLAINTEXT: "false"
DASHBOARD_CORS_ORIGIN: ${DASHBOARD_CORS_ORIGIN:-http://localhost:5173}
depends_on:
redis:
condition: service_healthy
cycles-server:
image: ghcr.io/runcycles/cycles-server:0.1.25.59
ports:
- "7878:7878"
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ""
# Same value as the admin server's ADMIN_API_KEY. Since 0.1.25.45 the
# aggregate /actuator/health, Prometheus, and API docs endpoints require
# this key; leaving it unset makes them return 500 "server
# misconfiguration" (liveness/readiness probes stay public).
ADMIN_API_KEY: ${ADMIN_API_KEY:-admin-bootstrap-key}
WEBHOOK_SECRET_ENCRYPTION_KEY: ${WEBHOOK_SECRET_ENCRYPTION_KEY:?WEBHOOK_SECRET_ENCRYPTION_KEY must be set}
DASHBOARD_CORS_ORIGIN: ${DASHBOARD_CORS_ORIGIN:-http://localhost:5173}
depends_on:
redis:
condition: service_healthy
# Optional: webhook delivery and CyclesEvidence signing service. Set
# the same WEBHOOK_SECRET_ENCRYPTION_KEY used by admin and runtime:
# export WEBHOOK_SECRET_ENCRYPTION_KEY=$(openssl rand -base64 32)
# Evidence signing is off unless EVIDENCE_SERVER_ID, EVIDENCE_SIGNING_SIGNER_DID,
# and the worker-only EVIDENCE_SIGNING_PRIVATE_KEY_HEX are configured.
# Docs: https://runcycles.io/quickstart/deploying-the-events-service
# cycles-events:
# image: ghcr.io/runcycles/cycles-server-events:0.1.25.25
# # No public inbound port is required. Add "9980:9980" only for local
# # management inspection; keep it internal in production.
# environment:
# REDIS_HOST: redis
# REDIS_PORT: 6379
# REDIS_PASSWORD: ""
# WEBHOOK_SECRET_ENCRYPTION_KEY: "${WEBHOOK_SECRET_ENCRYPTION_KEY:?WEBHOOK_SECRET_ENCRYPTION_KEY must be set}"
# WEBHOOK_SECRET_ALLOW_PLAINTEXT: "false"
# depends_on:
# redis:
# condition: service_healthy
volumes:
redis-data:
```
Start the stack:
```bash
docker compose up -d
```
::: tip Version pinning
The examples above pin the current compatible images: admin `0.1.25.55`, server `0.1.25.59`, and events `0.1.25.25`. Check the [current version matrix](/changelog#current-versions) before deploying. Admin, runtime, and events ship on independent release cadences — bumping one does not require bumping the others.
:::
Verify all services are healthy:
```bash
curl -s http://localhost:7878/actuator/health/readiness # Cycles Server
curl -s http://localhost:7979/actuator/health/readiness # Admin Server
```
Both should return `{"status":"UP"}`. The readiness probes are public; the aggregate `/actuator/health`, `/actuator/prometheus`, and API docs/Swagger endpoints require the `X-Admin-API-Key` header since cycles-server 0.1.25.45.
### Option B: Docker Compose from source (for development)
The repositories include multi-stage Dockerfiles that build the JARs inside Docker — no local Java or Maven installation required. Each repository includes a `docker-compose.full-stack.yml` that brings up Redis, the Cycles Server, the Admin Server, and the Events Service together.
Clone the repositories side by side (the full-stack compose builds all three from sibling directories):
```bash
git clone https://github.com/runcycles/cycles-server.git
git clone https://github.com/runcycles/cycles-server-admin.git
git clone https://github.com/runcycles/cycles-server-events.git
```
Start the full stack from either repo:
```bash
cd cycles-server-admin
docker compose -f docker-compose.full-stack.yml up -d
```
The multi-stage Docker build compiles the JARs automatically — no manual `mvn package` step needed.
Verify all services are healthy:
```bash
curl -s http://localhost:7878/actuator/health/readiness # Cycles Server
curl -s http://localhost:7979/actuator/health/readiness # Admin Server
```
Both should return `{"status":"UP"}`.
### Option C: Running from source
Start Redis:
```bash
docker run -d --name cycles-redis -p 6379:6379 redis:7-alpine
```
Build and start the admin server:
```bash
cd cycles-server-admin/cycles-admin-service
mvn clean package -DskipTests
REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= ADMIN_API_KEY=admin-bootstrap-key \
java -jar cycles-admin-service-api/target/cycles-admin-service-api-*.jar
```
In a second terminal, build and start the cycles server:
```bash
cd cycles-server/cycles-protocol-service
mvn clean package -DskipTests
REDIS_HOST=localhost REDIS_PORT=6379 \
java -jar cycles-protocol-service-api/target/cycles-protocol-service-api-*.jar
```
## Step 2: Create a tenant
Every budget and API key belongs to a tenant. Create one using the admin API.
::: info Two authentication headers
The next steps use two different headers. **`X-Admin-API-Key`** is the static bootstrap secret you set in docker-compose (`ADMIN_API_KEY`) — it's used here to create tenants and API keys. **`X-Cycles-API-Key`** is the tenant-scoped key you'll create in Step 3 — it's used for budget operations and runtime calls. For the full mapping of which endpoints use which header, see the [Architecture Overview — Authentication](/quickstart/architecture-overview-how-cycles-fits-together#authentication).
:::
::: tip
This step creates a single tenant for the quickstart. For the full tenant lifecycle — listing, updating, suspending, hierarchical tenants, and more — see [Tenant Creation and Management](/how-to/tenant-creation-and-management-in-cycles).
:::
```bash
curl -s -X POST http://localhost:7979/v1/admin/tenants \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: admin-bootstrap-key" \
-d '{
"tenant_id": "acme-corp",
"name": "Acme Corporation"
}' | jq .
```
You should see the tenant returned with its details.
## Step 3: Create an API key
Create a tenant-scoped API key. This is the key your application will use in the `X-Cycles-API-Key` header:
```bash
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",
"description": "Development key for acme-corp",
"permissions": [
"reservations:create",
"reservations:commit",
"reservations:release",
"reservations:extend",
"reservations:list",
"balances:read",
"budgets:write"
]
}' | jq .
```
**Important:** The response includes the full API key (e.g., `cyc_live_...`). Save it — the full secret is only returned once.
```bash
# Save the key for use in later steps
export CYCLES_API_KEY="cyc_live_..." # paste the key from the response
```
## Step 4: Create a budget
Create a budget ledger for the tenant. Without a budget at any derived scope, reservations fail with `404 NOT_FOUND` ("Budget not found for provided scope"):
```bash
curl -s -X POST http://localhost:7979/v1/admin/budgets \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"scope": "tenant:acme-corp",
"unit": "USD_MICROCENTS",
"allocated": { "amount": 10000000, "unit": "USD_MICROCENTS" }
}' | jq .
```
This creates a budget ledger with $0.10 (10,000,000 microcents) available to spend. The `allocated` amount is immediately available as spendable balance.
To add more funds later (e.g., on a schedule or when a customer upgrades), use the fund endpoint:
```bash
curl -s -X POST "http://localhost:7979/v1/admin/budgets/fund?scope=tenant:acme-corp&unit=USD_MICROCENTS" \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"operation": "CREDIT",
"amount": { "amount": 10000000, "unit": "USD_MICROCENTS" },
"idempotency_key": "topup-001",
"reason": "Budget top-up"
}' | jq .
```
::: info Note
The CREDIT operation adds to the existing balance. If you created the budget with 10M and then credit 10M, the total available becomes 20M.
:::
## Step 5: Verify the full lifecycle
Now test a complete reserve → commit cycle against the **Cycles Server** (port 7878):
```bash
# 1. Reserve
RESERVE_RESPONSE=$(curl -s -X POST http://localhost:7878/v1/reservations \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"idempotency_key": "test-deploy-001",
"subject": { "tenant": "acme-corp" },
"action": { "kind": "llm.completion", "name": "openai:gpt-4o" },
"estimate": { "amount": 500000, "unit": "USD_MICROCENTS" },
"ttl_ms": 30000,
"overage_policy": "ALLOW_IF_AVAILABLE"
}')
echo "$RESERVE_RESPONSE" | jq .
RESERVATION_ID=$(echo "$RESERVE_RESPONSE" | jq -r '.reservation_id')
echo "Reservation ID: $RESERVATION_ID"
```
You should see `"decision": "ALLOW"` and a `reservation_id`.
```bash
# 2. Commit actual spend
curl -s -X POST "http://localhost:7878/v1/reservations/$RESERVATION_ID/commit" \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"idempotency_key": "test-commit-001",
"actual": { "amount": 350000, "unit": "USD_MICROCENTS" },
"metrics": {
"tokens_input": 1200,
"tokens_output": 800,
"model_version": "gpt-4o-2024-05"
}
}' | jq .
```
You should see `"status": "COMMITTED"`.
```bash
# 3. Check the balance
curl -s "http://localhost:7878/v1/balances?tenant=acme-corp" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" | jq .
```
You should see `spent` has increased and `remaining` has decreased.
**Your deployment is working.** The full reserve-commit-balance cycle completed successfully.
## Optional: Enable CyclesEvidence
The quickstart stack enforces budgets without enabling signed evidence by default. To return `cycles_evidence` refs on runtime responses and make `GET /v1/evidence/{id}` resolve after the async signer runs, configure a shared public identity on `cycles-server` and `cycles-server-events`, and keep the private key only on `cycles-server-events`.
Runtime server:
```yaml
environment:
EVIDENCE_SERVER_ID: http://localhost:7878/v1
EVIDENCE_SIGNING_SIGNER_DID: <64-hex-public-ed25519-key>
EVIDENCE_SIGNING_KID: local-dev-1
EVIDENCE_SIGNING_NBF_MS:
```
Events service:
```yaml
environment:
EVIDENCE_SERVER_ID: http://localhost:7878/v1
EVIDENCE_SIGNING_SIGNER_DID:
EVIDENCE_SIGNING_PRIVATE_KEY_HEX: <64-hex-private-ed25519-seed>
```
`EVIDENCE_SIGNING_KID` is a public JWK `kid` label for the runtime server's JWKS endpoint. It is not key material and is not read by `cycles-server-events`. For key generation, coherence checks, and rotation, use the [CyclesEvidence envelope reference](/protocol/cycles-evidence-envelopes-in-cycles) and the [events-service deployment guide](/quickstart/deploying-the-events-service#optional-cyclesevidence-signing).
## Step 6: Connect your application
### Spring Boot (using the Cycles Spring Boot Starter)
Add the dependency:
```xml
io.runcyclescycles-client-java-spring0.3.2
```
Configure your project's `application.yml`:
```yaml
cycles:
base-url: http://localhost:7878
api-key: ${CYCLES_API_KEY}
tenant: acme-corp
```
Annotate methods:
```java
@Service
public class LlmService {
@Cycles(estimate = "#maxTokens * 10", unit = "USD_MICROCENTS",
actionKind = "llm.completion", actionName = "openai:gpt-4o")
public String generate(String prompt, int maxTokens) {
// Call your LLM provider here
return callOpenAI(prompt, maxTokens);
}
}
```
### Python / TypeScript (using the runcycles client)
Install the client:
::: code-group
```bash [Python]
pip install runcycles
```
```bash [TypeScript]
npm install runcycles
```
:::
Use the decorator or higher-order function for automatic reserve/execute/commit:
::: code-group
```python [Python]
from runcycles import CyclesClient, CyclesConfig, cycles, set_default_client
config = CyclesConfig(
base_url="http://localhost:7878",
api_key="cyc_live_...",
tenant="acme-corp",
)
client = CyclesClient(config)
set_default_client(client)
@cycles(estimate=5000, action_kind="llm.completion", action_name="openai:gpt-4o")
def generate(prompt: str) -> str:
return call_openai(prompt)
result = generate("Hello")
```
```typescript [TypeScript]
import { CyclesClient, CyclesConfig, withCycles, setDefaultClient } from "runcycles";
const config = new CyclesConfig({
baseUrl: "http://localhost:7878",
apiKey: "cyc_live_...",
tenant: "acme-corp",
});
const client = new CyclesClient(config);
setDefaultClient(client);
const generate = withCycles(
{ estimate: 5000, actionKind: "llm.completion", actionName: "openai:gpt-4o" },
async (prompt: string) => {
return await callOpenAI(prompt);
},
);
const result = await generate("Hello");
```
:::
Use `CyclesConfig.from_env()` (Python) or `CyclesConfig.fromEnv()` (TypeScript) to load from `CYCLES_BASE_URL`, `CYCLES_API_KEY`, and `CYCLES_TENANT` environment variables. See the [Python Client quickstart](/quickstart/getting-started-with-the-python-client) or [TypeScript Client quickstart](/quickstart/getting-started-with-the-typescript-client) for full details.
### Any language (raw HTTP)
Any HTTP client can use Cycles. The protocol is language-agnostic:
```python
import requests
CYCLES_URL = "http://localhost:7878"
API_KEY = "cyc_live_..."
# Reserve
resp = requests.post(f"{CYCLES_URL}/v1/reservations", json={
"idempotency_key": "py-001",
"subject": {"tenant": "acme-corp"},
"action": {"kind": "llm.completion", "name": "openai:gpt-4o"},
"estimate": {"amount": 500000, "unit": "USD_MICROCENTS"},
"ttl_ms": 30000
}, headers={"X-Cycles-API-Key": API_KEY})
reservation_id = resp.json()["reservation_id"]
# ... call the LLM ...
# Commit
requests.post(f"{CYCLES_URL}/v1/reservations/{reservation_id}/commit", json={
"idempotency_key": "py-commit-001",
"actual": {"amount": 420000, "unit": "USD_MICROCENTS"}
}, headers={"X-Cycles-API-Key": API_KEY})
```
## Environment variable reference
### Cycles Server (port 7878)
| Variable | Default | Description |
|---|---|---|
| `REDIS_HOST` | `localhost` | Redis hostname |
| `REDIS_PORT` | `6379` | Redis port |
| `REDIS_PASSWORD` | (empty) | Redis password |
| `server.port` | `7878` | HTTP port |
| `cycles.expiry.interval-ms` | `5000` | Reservation expiry sweep interval (ms) |
| `EVIDENCE_SERVER_ID` | (empty) | Public CyclesEvidence issuer base including `/v1`; set with `EVIDENCE_SIGNING_SIGNER_DID` to emit evidence refs |
| `EVIDENCE_SIGNING_SIGNER_DID` | (empty) | Raw-hex public Ed25519 key; must match `cycles-server-events` when evidence is enabled |
| `EVIDENCE_SIGNING_KID` | derived | Public JWK `kid` label for `GET /v1/.well-known/cycles-jwks.json`; not key material |
| `EVIDENCE_SIGNING_NBF_MS` | `0` | Active JWK validity start, epoch ms |
| `EVIDENCE_SIGNING_RETIRED_KEYS` | (empty) | JSON rotation history for retired public signing keys |
### Cycles Admin Server (port 7979)
| Variable | Default | Description |
|---|---|---|
| `REDIS_HOST` | (required) | Redis hostname |
| `REDIS_PORT` | (required) | Redis port |
| `REDIS_PASSWORD` | (required) | Redis password (set empty string if none) |
| `ADMIN_API_KEY` | (empty) | Master admin key for `X-Admin-API-Key` header |
| `server.port` | `7979` | HTTP port |
### Cycles Events Service (outbound worker)
| Variable | Default | Description |
|---|---|---|
| `REDIS_HOST` | `localhost` | Redis hostname |
| `REDIS_PORT` | `6379` | Redis port |
| `REDIS_PASSWORD` | (empty) | Redis password |
| `MANAGEMENT_PORT` | `9980` | Separate management port for health and Prometheus; keep internal-only |
| `EVENT_TTL_DAYS` | `90` | Event record retention (days) |
| `DELIVERY_TTL_DAYS` | `14` | Webhook delivery record retention (days) |
| `WEBHOOK_SECRET_ENCRYPTION_KEY` | required by default | AES-256-GCM key for webhook signing secrets at rest. Missing key fails admin/events startup unless the local-development-only `WEBHOOK_SECRET_ALLOW_PLAINTEXT=true` escape hatch is set. |
| `EVIDENCE_SERVER_ID` | (empty) | Same issuer base as the runtime server. Blank disables evidence signing and leaves pending evidence records untouched. |
| `EVIDENCE_SIGNING_SIGNER_DID` | (empty) | Same raw-hex public Ed25519 key as the runtime server |
| `EVIDENCE_SIGNING_PRIVATE_KEY_HEX` | (empty) | Raw-hex private Ed25519 key; set only on `cycles-server-events` |
## Troubleshooting
### "NOT_FOUND" (no budget) or "BUDGET_EXCEEDED" on first reservation
`404 NOT_FOUND` means no budget exists at any derived scope — create a budget ledger via the admin API (Step 4); at least one scope in the subject hierarchy needs an allocated budget. `409 BUDGET_EXCEEDED` means a budget exists but the estimate exceeds what remains.
### "UNAUTHORIZED" or 401
The API key is missing, invalid, or expired. Verify with:
```bash
curl -s -X POST http://localhost:7979/v1/auth/validate \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: admin-bootstrap-key" \
-d '{"key_secret": "cyc_live_..."}' | jq .
```
### Connection refused on port 7878 or 7979
The server is not running. Check Docker containers (`docker compose ps`) or check that the Java processes are running.
### "DEBT_OUTSTANDING" on new reservations
A scope has accumulated debt from `ALLOW_WITH_OVERDRAFT` commits and has no `overdraft_limit` configured (or it is 0). When an `overdraft_limit > 0` is set, debt within the limit does not block reservations. To resolve, either repay the debt or configure an overdraft limit. Repay via the admin API:
```bash
curl -s -X POST "http://localhost:7979/v1/admin/budgets/fund?scope=tenant:acme-corp&unit=USD_MICROCENTS" \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"operation": "REPAY_DEBT",
"amount": { "amount": 500000, "unit": "USD_MICROCENTS" },
"idempotency_key": "repay-001"
}' | jq .
```
### Docker daemon not running
If `docker compose up` fails with "Cannot connect to the Docker daemon", ensure Docker Desktop is running (macOS/Windows) or that the Docker service is started (`sudo systemctl start docker` on Linux).
### Port conflicts
If you see "port is already allocated", another service is using port 6379, 7878, or 7979. Stop the conflicting service or change the port mapping in your `docker-compose.yml` (e.g., `"7879:7878"`).
### Redis connection errors
Ensure Redis 7+ is running and accessible at the configured host:port. Test with:
```bash
redis-cli -h $REDIS_HOST -p $REDIS_PORT ping
```
## Next steps
- [Architecture Overview](/quickstart/architecture-overview-how-cycles-fits-together) — how the components interact in detail
- [Server Configuration Reference](/configuration/server-configuration-reference-for-cycles) — all server configuration properties
- [Getting Started with the MCP Server](/quickstart/getting-started-with-the-mcp-server) — add runtime authority to Claude Desktop, Claude Code, Cursor, or Windsurf
- [Getting Started with the Spring Boot Starter](/quickstart/getting-started-with-the-cycles-spring-boot-starter) — full Spring Boot integration guide
- [Budget Allocation and Management](/how-to/budget-allocation-and-management-in-cycles) — budget patterns and strategies
- [API Key Management](/how-to/api-key-management-in-cycles) — key rotation, scoping, and security
# End-to-End Tutorial: Zero to Budget-Guarded LLM Call
This tutorial takes you from nothing to a working budget-guarded OpenAI call in about 10 minutes. You will deploy the Cycles stack, create a tenant, fund a budget, and make your first budget-enforced LLM call.
::: tip Want to see Cycles in action before building?
Check out the [Demos](/demos/) — self-contained scenarios you can run in 60 seconds, no LLM key required.
:::
## Prerequisites
- **Docker** and **Docker Compose v2+**
- **Python 3.10+** or **Node.js 20+** (for the application step)
- An **OpenAI API key** (for the final step — or use the mock tabs below if you don't have one)
## Quick code preview
Want to see what Cycles integration looks like before setting up the stack? Here is the complete pattern:
::: code-group
```python [Python]
from runcycles import CyclesClient, CyclesConfig, cycles, set_default_client
client = CyclesClient(CyclesConfig(
base_url="http://localhost:7878", # Cycles server
api_key="cyc_live_...", # from the admin API
tenant="my-app",
))
set_default_client(client)
@cycles(estimate=2000000, action_kind="llm.completion", action_name="openai:gpt-4o-mini")
def ask(prompt: str) -> str:
return call_your_llm(prompt) # any LLM provider
result = ask("Hello") # Budget reserved → LLM called → cost committed
```
```typescript [TypeScript]
import { CyclesClient, CyclesConfig, withCycles, setDefaultClient } from "runcycles";
const client = new CyclesClient(new CyclesConfig({
baseUrl: "http://localhost:7878",
apiKey: "cyc_live_...",
tenant: "my-app",
}));
setDefaultClient(client);
const ask = withCycles(
{ estimate: 2000000, actionKind: "llm.completion", actionName: "openai:gpt-4o-mini" },
async (prompt: string) => callYourLlm(prompt),
);
const result = await ask("Hello"); // Budget reserved → LLM called → cost committed
```
:::
::: info
This code requires a running Cycles server. The tutorial below walks you through setting one up with Docker in about 2 minutes. If you just want to see a demo without any setup, check the [Demos](/demos/) page instead.
:::
## Step 1: Start the Cycles stack
Create a `docker-compose.yml` and start the infrastructure:
```bash
cat > docker-compose.yml <<'COMPOSE'
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
cycles-admin:
image: ghcr.io/runcycles/cycles-server-admin:0.1.25.55
ports: ["7979:7979"]
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ""
ADMIN_API_KEY: admin-bootstrap-key
# Local tutorial only. Production must set WEBHOOK_SECRET_ENCRYPTION_KEY.
WEBHOOK_SECRET_ALLOW_PLAINTEXT: "true"
depends_on:
redis: { condition: service_healthy }
cycles-server:
image: ghcr.io/runcycles/cycles-server:0.1.25.59
ports: ["7878:7878"]
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ""
# Same key as the admin server - used for admin-on-behalf-of
# endpoints and the protected actuator/API-docs surface (unset,
# those endpoints return 500 server-misconfiguration, not 401)
ADMIN_API_KEY: admin-bootstrap-key
depends_on:
redis: { condition: service_healthy }
COMPOSE
docker compose up -d
```
Wait for services to be healthy (use the readiness probe — since 0.1.25.45 the aggregate `/actuator/health` requires the `X-Admin-API-Key` header, while liveness/readiness stay public):
```bash
until curl -sf http://localhost:7878/actuator/health/readiness > /dev/null 2>&1; do sleep 1; done
until curl -sf http://localhost:7979/actuator/health/readiness > /dev/null 2>&1; do sleep 1; done
echo "Cycles is running."
```
## Step 2: Create a tenant
::: info Two authentication headers
Steps 2-3 use `X-Admin-API-Key` — the static bootstrap secret set in docker-compose (`ADMIN_API_KEY`). Steps 4-5 switch to `X-Cycles-API-Key` — the tenant-scoped key created in Step 3. See [Authentication](/quickstart/architecture-overview-how-cycles-fits-together#authentication) for why.
:::
```bash
curl -s -X POST http://localhost:7979/v1/admin/tenants \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: admin-bootstrap-key" \
-d '{"tenant_id": "my-app", "name": "My Application"}' | jq .
```
## Step 3: Create an API key
```bash
API_KEY=$(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": "my-app",
"name": "tutorial-key",
"permissions": ["reservations:create","reservations:commit","reservations:release","reservations:extend","reservations:list","balances:read","budgets:write"]
}' | jq -r '.key_secret')
echo "Your API key: $API_KEY"
```
Save this key — the secret is only shown once.
## Step 4: Create a budget
Give the tenant $1.00 (100,000,000 microcents) to spend:
```bash
curl -s -X POST http://localhost:7979/v1/admin/budgets \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $API_KEY" \
-d '{
"scope": "tenant:my-app",
"unit": "USD_MICROCENTS",
"allocated": { "amount": 100000000, "unit": "USD_MICROCENTS" }
}' | jq .
```
## Step 5: Verify with a raw HTTP test
Before adding an SDK, confirm the lifecycle works with curl:
```bash
# Reserve
RESERVATION_ID=$(curl -s -X POST http://localhost:7878/v1/reservations \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $API_KEY" \
-d '{
"idempotency_key": "tutorial-001",
"subject": {"tenant": "my-app"},
"action": {"kind": "llm.completion", "name": "test"},
"estimate": {"amount": 500000, "unit": "USD_MICROCENTS"},
"ttl_ms": 30000
}' | jq -r '.reservation_id')
echo "Reserved: $RESERVATION_ID"
# Commit
curl -s -X POST "http://localhost:7878/v1/reservations/$RESERVATION_ID/commit" \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $API_KEY" \
-d '{"idempotency_key": "tutorial-commit-001", "actual": {"amount": 350000, "unit": "USD_MICROCENTS"}}' | jq .
# Check balance
curl -s "http://localhost:7878/v1/balances?tenant=my-app" \
-H "X-Cycles-API-Key: $API_KEY" | jq .
```
You should see `"decision": "ALLOW"`, then `"status": "COMMITTED"`, then a balance with `spent` of 350,000 and the remaining budget reduced accordingly.
## Step 6: Build a budget-guarded application
Choose your language:
::: code-group
```python [Python]
# Install: pip install runcycles openai
# Save as app.py
import os
from openai import OpenAI
from runcycles import CyclesClient, CyclesConfig, cycles, set_default_client
# Configure Cycles
cycles_client = CyclesClient(CyclesConfig(
base_url="http://localhost:7878",
api_key=os.environ["CYCLES_API_KEY"],
tenant="my-app",
))
set_default_client(cycles_client)
# Configure OpenAI
openai_client = OpenAI()
@cycles(
estimate=2000000, # Reserve $0.02 per call
action_kind="llm.completion",
action_name="openai:gpt-4o-mini",
)
def ask(prompt: str) -> str:
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
max_tokens=500,
)
return response.choices[0].message.content
# Run it
try:
result = ask("What is budget governance for AI agents? Reply in one sentence.")
print(f"Response: {result}")
except Exception as e:
print(f"Error: {e}")
```
```typescript [TypeScript]
// Install: npm init -y && npm install runcycles openai
// Save as app.ts
import OpenAI from "openai";
import { CyclesClient, CyclesConfig, withCycles, setDefaultClient } from "runcycles";
const cyclesClient = new CyclesClient(new CyclesConfig({
baseUrl: "http://localhost:7878",
apiKey: process.env.CYCLES_API_KEY!,
tenant: "my-app",
}));
setDefaultClient(cyclesClient);
const openai = new OpenAI();
const ask = withCycles(
{
estimate: 2000000,
actionKind: "llm.completion",
actionName: "openai:gpt-4o-mini",
},
async (prompt: string) => {
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
max_tokens: 500,
});
return response.choices[0].message.content;
},
);
const result = await ask("What is budget governance for AI agents? Reply in one sentence.");
console.log("Response:", result);
```
```python [Python (mock)]
# Install: pip install runcycles
# Save as app_mock.py — no OpenAI key needed
import os
from runcycles import CyclesClient, CyclesConfig, cycles, set_default_client
# Configure Cycles
cycles_client = CyclesClient(CyclesConfig(
base_url="http://localhost:7878",
api_key=os.environ["CYCLES_API_KEY"],
tenant="my-app",
))
set_default_client(cycles_client)
@cycles(
estimate=2000000, # Reserve $0.02 per call
action_kind="llm.completion",
action_name="mock:gpt-4o-mini",
)
def ask(prompt: str) -> str:
# Simulated LLM response — no API key required
return f"[Mock response to: {prompt[:50]}]"
# Run it
try:
result = ask("What is budget governance for AI agents? Reply in one sentence.")
print(f"Response: {result}")
except Exception as e:
print(f"Error: {e}")
```
```typescript [TypeScript (mock)]
// Install: npm init -y && npm install runcycles
// Save as app_mock.ts — no OpenAI key needed
import { CyclesClient, CyclesConfig, withCycles, setDefaultClient } from "runcycles";
const cyclesClient = new CyclesClient(new CyclesConfig({
baseUrl: "http://localhost:7878",
apiKey: process.env.CYCLES_API_KEY!,
tenant: "my-app",
}));
setDefaultClient(cyclesClient);
const ask = withCycles(
{
estimate: 2000000,
actionKind: "llm.completion",
actionName: "mock:gpt-4o-mini",
},
async (prompt: string) => {
// Simulated LLM response — no API key required
return `[Mock response to: ${prompt.slice(0, 50)}]`;
},
);
const result = await ask("What is budget governance for AI agents? Reply in one sentence.");
console.log("Response:", result);
```
:::
::: tip No OpenAI key?
The **mock tabs** replace the OpenAI call with a stub that returns a fixed string. The Cycles budget lifecycle (reserve → commit → balance deduction) works exactly the same — you just skip the LLM cost.
:::
Run it:
```bash
export CYCLES_API_KEY="cyc_live_..." # your key from Step 3
# With OpenAI:
export OPENAI_API_KEY="sk-..."
python app.py # or: npx tsx app.ts
# Without OpenAI (mock):
python app_mock.py # or: npx tsx app_mock.ts
```
## Step 7: Watch the budget decrease
After running your app, check the balance again:
```bash
curl -s "http://localhost:7878/v1/balances?tenant=my-app" \
-H "X-Cycles-API-Key: $API_KEY" | jq '.balances[] | {scope, remaining, spent, reserved}'
```
You'll see `spent` has increased by the actual usage from your LLM call, and `remaining` has decreased.
## Step 8: See what happens when budget runs out
Try exhausting the budget to see enforcement in action. Set a tiny budget on a new scope:
```bash
curl -s -X POST http://localhost:7979/v1/admin/budgets \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $API_KEY" \
-d '{
"scope": "tenant:my-app/workspace:demo",
"unit": "USD_MICROCENTS",
"allocated": { "amount": 100, "unit": "USD_MICROCENTS" }
}' | jq .
```
Now try to reserve more than the budget:
```bash
curl -s -X POST http://localhost:7878/v1/reservations \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $API_KEY" \
-d '{
"idempotency_key": "exceed-001",
"subject": {"tenant": "my-app", "workspace": "demo"},
"action": {"kind": "llm.completion", "name": "test"},
"estimate": {"amount": 500000, "unit": "USD_MICROCENTS"},
"ttl_ms": 30000
}' | jq .
```
You'll see `"error": "BUDGET_EXCEEDED"` — the call was blocked *before* any money was spent.
## Cleanup
```bash
docker compose down -v
```
## Common issues
- **`Cannot connect to the Docker daemon`** — Docker Desktop isn't running. Start it and re-run `docker compose up -d`.
- **`bind: address already in use` on port 7878 or 7979** — another service is using these ports. Either stop it, or remap the ports in `docker-compose.yml` (e.g. `"17878:7878"`) and update the curl URLs accordingly.
- **`401 Unauthorized` on Step 2 or 3** — you're using `X-Cycles-API-Key` instead of `X-Admin-API-Key`. Bootstrap calls (creating tenants and API keys) require the admin header. Tenant calls (Steps 4–8) use the Cycles header.
- **`jq: command not found`** — install jq (`brew install jq` on macOS, `apt install jq` on Debian/Ubuntu, `winget install jqlang.jq` on Windows). Or pipe to `python -m json.tool` instead.
- **`tenant_id mismatch` or `tenant not found`** — every step uses `my-app` as the tenant ID. If you changed it in Step 2, update it everywhere else too (including the `subject.tenant` field in reservations).
- **Healthcheck loop never returns** — the `until curl -sf ...` loop is waiting for `/actuator/health/readiness` to return 200. Check `docker compose logs cycles-server` for startup errors (most often a Redis connection issue). If you queried the aggregate `/actuator/health` instead and got `401`, that is expected — it requires `X-Admin-API-Key` since 0.1.25.45.
## Next steps
::: tip Want real-time budget alerts?
The tutorial above deploys the core budget enforcement stack. To receive webhook notifications when budgets run out, thresholds are crossed, or reservations are denied, add the optional events service. It takes 2 minutes.
:::
- [Deploy the Events Service](/quickstart/deploying-the-events-service) — get Slack, PagerDuty, or custom webhook alerts for budget events
- [Python Client Quickstart](/quickstart/getting-started-with-the-python-client) — `@cycles` decorator deep dive
- [TypeScript Client Quickstart](/quickstart/getting-started-with-the-typescript-client) — `withCycles` wrapper deep dive
- [Spring Boot Quickstart](/quickstart/getting-started-with-the-cycles-spring-boot-starter) — `@Cycles` annotation deep dive
- [Demos](/demos/) — see Cycles in action with the runaway agent and action authority scenarios
- [Choose a First Rollout](/quickstart/how-to-choose-a-first-cycles-rollout-tenant-budgets-run-budgets-or-model-call-guardrails) — decide your adoption strategy
- [Adding Cycles to an Existing Application](/how-to/adding-cycles-to-an-existing-application) — integrate incrementally
- [Cost Estimation Cheat Sheet](/how-to/cost-estimation-cheat-sheet) — how much to reserve per model call
# Getting Started with the Cycles Spring Boot Starter
[](https://central.sonatype.com/artifact/io.runcycles/cycles-client-java-spring)
The Cycles Spring Boot Starter provides a declarative way to add budget enforcement to any Spring application.
Instead of manually calling the Cycles API for every reservation, commit, and release, the starter provides an `@Cycles` annotation that handles the full lifecycle automatically.
::: tip Using Python?
See the [Python Client quickstart](/quickstart/getting-started-with-the-python-client) instead.
:::
## What the starter does
The starter wraps any annotated method in a reserve → execute → commit lifecycle:
1. **Before the method runs:** evaluates the estimate, creates a reservation, and checks the decision
2. **While the method runs:** maintains the reservation with automatic heartbeat extensions
3. **After the method returns:** commits actual usage and releases any unused remainder
4. **If the method throws:** releases the reservation to return budget to the pool
Once actual usage is known, the starter persists settlement before the first commit request. Ambiguous outcomes replay with the same key, and an expired commit is recovered through `POST /v1/events`. The guarantee cannot cover a JVM exit before actual usage is known; see [SDK Settlement Recovery and Durability](/protocol/sdk-settlement-recovery-and-durability).
::: tip Cycles provides three runtime-authority pillars
- **Spend** — reserve-commit budget enforcement before instrumented LLM calls and tool actions
- **Risky actions** — callers can budget assigned `RISK_POINTS`; applications must apply preflight decisions and any configured caps
- **Audit** — reservations, commits, releases, and direct-usage events create lifecycle records; non-persisting preflight decisions need application logging
:::
All of this happens transparently through Spring AOP.
## Try the demo app first
The fastest way to see the starter in action is to run the included demo application.
### Prerequisites
You need a running Cycles stack with a tenant, API key, and budget. If you don't have one yet, follow [Deploy the Full Stack](/quickstart/deploying-the-full-cycles-stack) first. The demo app expects the same `acme-corp` tenant used in that guide.
::: tip Where do I get my API key?
API keys are created through the **Cycles Admin Server** (port 7979) and always start with `cyc_live_`. If your stack is already running with a tenant, create one directly:
```bash
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"]
}' | jq -r '.key_secret'
```
The response returns the full key (e.g. `cyc_live_abc123...`). **Save it — the secret is only shown once.**
Need the full setup? See [Deploy the Full Stack — Create an API key](/quickstart/deploying-the-full-cycles-stack#step-3-create-an-api-key). For rotation and lifecycle details, see [API Key Management](/how-to/api-key-management-in-cycles).
:::
### Run the demo
```bash
git clone https://github.com/runcycles/cycles-spring-boot-starter.git
cd cycles-spring-boot-starter/cycles-demo-client-java-spring
```
Set your API key — the `cyc_live_...` value returned when you created the key via the Admin Server:
```bash
export CYCLES_API_KEY=cyc_live_... # from Admin Server /v1/admin/api-keys response
mvn spring-boot:run
```
Or edit `application.yml` directly and paste the key in `cycles.api-key`.
The demo app starts on port 7955. Try the simplest example first:
```bash
curl -X POST http://localhost:7955/api/demo/annotation/minimal?input=hello
```
Hit `GET http://localhost:7955/api/demo/index` for a full listing of all endpoints with copy-paste curl commands.
### What the demo covers
The demo app includes working examples for every major feature area:
**Start here (`/api/demo/annotation/minimal`)**
- `@Cycles("1000")` — the simplest possible usage, fixed estimate with all defaults
**Annotation-based (`/api/llm/*`)**
- `@Cycles` with SpEL estimate/actual, `CyclesContextHolder` for reading reservation context, `CyclesMetrics` for reporting token counts and latency, and `commitMetadata` for audit data
**Annotation variations (`/api/demo/annotation/*`)**
- `ALLOW_WITH_CAPS` — reading and respecting operator-configured constraints returned by the server — `POST /api/demo/annotation/caps`
- `unit=TOKENS` with `actionTags` — `POST /api/demo/annotation/tokens`
- `unit=CREDITS` with `workflow`, `agent`, and custom `dimensions` — `POST /api/demo/annotation/credits`
- Per-annotation budget scope targeting (`workspace`/`app` override) — `POST /api/demo/annotation/budget-targeting`
- `overagePolicy=ALLOW_WITH_OVERDRAFT` — `POST /api/demo/annotation/overdraft`
- Custom `ttlMs` and `gracePeriodMs` — `POST /api/demo/annotation/custom-ttl`
- `dryRun=true` (shadow-mode evaluation) — `POST /api/demo/annotation/dry-run`
**Programmatic CyclesClient (`/api/demo/client/*`)**
- Full reserve → commit lifecycle — `POST /api/demo/client/reserve-commit`
- Reserve → release (cancellation) — `POST /api/demo/client/reserve-release`
- Preflight decision check — `POST /api/demo/client/decide`
- Balance queries — `GET /api/demo/client/balances`
- Reservation listing — `GET /api/demo/client/reservations`
**Standalone events (`/api/demo/events/*`)**
- Direct debit without reservation — `POST /api/demo/events/record`
**Error handling**
- Global `@RestControllerAdvice` for `CyclesProtocolException` with structured JSON error responses
::: info Note
The deployment guide creates a `USD_MICROCENTS` budget. The `unit=TOKENS` and `unit=CREDITS` demo endpoints require separate budget ledgers for those units. If you only created the default budget, those endpoints will return `BUDGET_EXCEEDED`. Start with the `USD_MICROCENTS` endpoints (minimal, caps, overdraft, custom-ttl, dry-run) and create additional budgets via the admin API if you want to explore other units.
:::
### Suggested walkthrough
Follow this order to build understanding progressively. Each step introduces one new concept.
**1. Reserve and commit with a fixed estimate**
```bash
curl -X POST http://localhost:7955/api/demo/annotation/minimal?input=hello
```
This is `@Cycles("1000")` — the simplest annotation. The response shows the reservation lifecycle result.
**2. Check your balance**
```bash
curl http://localhost:7955/api/demo/client/balances
```
You should see `spent` increased by 1000 microcents from step 1.
**3. Try a dry run (no budget consumed)**
```bash
curl -X POST http://localhost:7955/api/demo/annotation/dry-run?amount=500
```
The server evaluates the reservation but doesn't persist it. Check balances again — they haven't changed.
**4. See how overdraft works**
```bash
curl -X POST http://localhost:7955/api/demo/annotation/overdraft?amount=1000
```
With `overagePolicy=ALLOW_WITH_OVERDRAFT`, the reservation succeeds even if it exceeds the budget.
**5. Use the programmatic client**
```bash
curl -X POST http://localhost:7955/api/demo/client/reserve-commit?estimate=5000
```
This does the same reserve → commit lifecycle as `@Cycles`, but using `CyclesClient` directly. Compare with step 1 to see the annotation vs programmatic approach.
**6. Cancel a reservation**
```bash
curl -X POST http://localhost:7955/api/demo/client/reserve-release?estimate=3000
```
The reservation is created then released — budget is returned to the pool. Check balances to confirm it was refunded.
**7. Preflight check without reserving**
```bash
curl -X POST http://localhost:7955/api/demo/client/decide?estimate=10000
```
The `decide` endpoint tells you whether a reservation *would* be allowed, without creating one.
**8. Record a standalone event**
```bash
curl -X POST 'http://localhost:7955/api/demo/events/record?amount=1500&description=API+call'
```
Direct debit — no reservation needed. Useful for post-hoc usage recording.
After this walkthrough, explore the remaining endpoints (`caps`, `custom-ttl`, `llm/generate`) and read the source files below to see how each feature is implemented.
### Demo app source files
| File | What it demonstrates |
|---|---|
| `service/LlmService.java` | `@Cycles` annotation, `CyclesContextHolder`, `CyclesMetrics`, `commitMetadata` |
| `service/AnnotationShowcaseService.java` | Annotation variations: minimal, caps-aware, units, TTL, overdraft, dry-run, dimensions, budget scope targeting |
| `service/ProgrammaticClientService.java` | Direct `CyclesClient` usage for the full reservation lifecycle |
| `service/EventService.java` | Standalone events via `CyclesClient.createEvent()` |
| `error/CyclesExceptionHandler.java` | Global error handling for `CyclesProtocolException` |
| `resolvers/CyclesTenantResolver.java` | Dynamic tenant resolution via `CyclesFieldResolver` |
| `controller/DemoController.java` | REST endpoints wiring all services at `/api/demo/*` |
| `controller/LlmController.java` | LLM endpoints with budget error handling |
All demo source files are under `cycles-demo-client-java-spring/src/main/java/io/runcycles/demo/client/spring/`.
## Installation
Add the starter to your project:
::: code-group
```xml [Maven]
io.runcyclescycles-client-java-spring0.3.3
```
```groovy [Gradle]
implementation 'io.runcycles:cycles-client-java-spring:0.3.3'
```
:::
Check [Maven Central](https://central.sonatype.com/artifact/io.runcycles/cycles-client-java-spring) for the latest version.
## Configuration
Configure the connection in your project's `application.yml`:
```yaml
cycles:
base-url: http://localhost:7878
api-key: your-api-key
tenant: acme
workspace: production
app: support-bot
```
These defaults apply to all `@Cycles`-annotated methods unless overridden per method.
### Optional configuration
```yaml
cycles:
http:
connect-timeout: 2s
read-timeout: 5s
retry:
enabled: true
max-attempts: 5
initial-delay: 500ms
multiplier: 2.0
max-delay: 30s
```
## The @Cycles annotation
The `@Cycles` annotation is applied to methods:
```java
@Cycles("500")
public String summarize(String text) {
return chatModel.call(text);
}
```
This reserves 500 units (default unit: USD_MICROCENTS) before `summarize()` runs, then commits actual usage afterward.
### SpEL expressions for dynamic estimates
The estimate can use Spring Expression Language to compute cost from method arguments:
```java
@Cycles("#tokens * 10")
public String generate(int tokens) {
return chatModel.call(prompt, tokens);
}
```
The expression is evaluated before the method runs, using method parameters as variables.
::: warning Compiler flag required for named parameters
SpEL expressions that reference parameters by name (e.g., `#tokens`) require the `-parameters` javac compiler flag. Without it, you'll get a SpEL evaluation error at runtime. Add this to your `pom.xml`:
```xml
org.apache.maven.pluginsmaven-compiler-plugintrue
```
Alternatively, use positional references: `#p0`, `#p1`, etc. See the [SpEL Expression Reference](/configuration/spel-expression-reference-for-cycles) for details.
:::
### Specifying actual usage
By default, the estimate is used as the actual amount at commit time. To calculate actual usage from the return value:
```java
@Cycles(estimate = "5000", actual = "#result.usage.totalTokens * 8")
public ChatResponse chat(String prompt) {
return chatModel.call(prompt);
}
```
The `actual` expression is evaluated after the method returns, with `#result` bound to the return value.
In v0.3.3+, an invalid explicit `actual` expression cannot turn completed work
into a release: the starter logs the evaluation failure, commits the estimate,
and adds `metadata.actual_source=estimate`. If estimate fallback is disabled
and no `actual` expression is configured, validation fails before the
reservation or method execution.
## Annotation attributes
### Subject fields
Override the defaults from configuration:
```java
@Cycles(value = "1000",
tenant = "acme",
workspace = "production",
app = "support-bot",
workflow = "refund-assistant",
agent = "planner",
toolset = "search-tools")
```
Subject fields determine which budget scope the reservation targets. In most cases, set `tenant`, `workspace`, and `app` in configuration and only override specific fields per-method:
```java
// Given config: tenant=acme, workspace=production, app=support-bot
// Uses config defaults → scope: tenant:acme/workspace:production/app:support-bot
@Cycles("500")
public String handleTicket(String text) { ... }
// Overrides workspace → scope: tenant:acme/workspace:staging/app:support-bot
@Cycles(value = "500", workspace = "staging")
public String handleTicketStaging(String text) { ... }
```
The second method targets the **staging** budget scope instead of production. All other fields (`tenant`, `app`) still come from config. Budget scopes are independent — each has its own allocated budget.
Since 0.2.1, subject fields also accept SpEL: a value whose first non-whitespace character is `#` is evaluated against the method invocation, so `tenant = "#tenantId"` resolves the tenant from a method argument. Literal values are passed through unchanged.
### Action identity
```java
@Cycles(value = "1000",
actionKind = "llm.completion",
actionName = "openai:gpt-4o-mini",
actionTags = {"prod", "customer-facing"})
```
If not specified, `actionKind` defaults to the declaring class name and `actionName` defaults to the method name.
### Unit
```java
@Cycles(value = "2500", unit = "TOKENS")
```
Supported units: `USD_MICROCENTS` (default), `TOKENS`, `CREDITS`, `RISK_POINTS`.
### TTL and grace period
```java
@Cycles(value = "1000", ttlMs = 30000, gracePeriodMs = 10000)
```
Default TTL is 60 seconds. When the server returns `remaining_ttl_ms`, the starter schedules from that authoritative lead and reserves enough time for one failed extend, a same-key retry, and margin. Older servers use a best-effort fallback. See [TTL, Grace Period, and Extend](/protocol/reservation-ttl-grace-period-and-extend-in-cycles).
### Overage policy
```java
@Cycles(value = "1000", overagePolicy = "ALLOW_IF_AVAILABLE")
```
Options: `ALLOW_IF_AVAILABLE` (default), `REJECT`, `ALLOW_WITH_OVERDRAFT`.
### Dry run (shadow mode)
```java
@Cycles(value = "1000", dryRun = true)
```
Evaluates the reservation without actually holding budget. The guarded method does **not** execute — instead, the call returns a `DryRunResult` with the full evaluation data: decision, caps, affected scopes, scope path, reserved amount, balances, reason code, and retry hint. A dry-run `DENY` throws `CyclesProtocolException`, just like a real denial. Because the return value is a `DryRunResult` rather than the method's normal result, declare dry-run methods with an `Object` return type. Useful for shadow-mode rollouts where you want to measure budget impact without affecting production behavior.
### Custom dimensions
```java
@Cycles(value = "1000", dimensions = {"cost_center=engineering", "run=run-12345"})
```
### Commit metadata
```java
@Cycles(value = "1000", metadata = "{'app_request_id': #requestId, 'model': #result.model}")
```
Since 0.2.5. The `metadata` SpEL expression is evaluated after the method returns — `#result` is available — and must yield a `Map`. The result is merged with metadata set programmatically via `CyclesContextHolder`; programmatic metadata wins on key conflicts.
## Accessing reservation context at runtime
Inside an annotated method, the current reservation context is available via `CyclesContextHolder`:
```java
@Cycles("1000")
public String process(String input) {
CyclesReservationContext ctx = CyclesContextHolder.get();
// Check reservation details
String reservationId = ctx.getReservationId();
Decision decision = ctx.getDecision();
// Check caps (if ALLOW_WITH_CAPS)
if (ctx.hasCaps()) {
Caps caps = ctx.getCaps();
Integer maxTokens = caps.getMaxTokens();
if (!caps.isToolAllowed("web.search")) {
// skip web search
}
}
// Check expiration
if (ctx.isExpiringSoon(5000)) {
// wrap up quickly
}
// Attach metrics for the commit
CyclesMetrics metrics = new CyclesMetrics();
metrics.setTokensInput(150);
metrics.setTokensOutput(80);
metrics.setLatencyMs(320);
metrics.setModelVersion("gpt-4o-mini-2024-07-18");
ctx.setMetrics(metrics);
// Attach metadata for audit
ctx.setCommitMetadata(Map.of("app_request_id", "req-abc-123"));
return chatModel.call(input);
}
```
## Decision handling
When the reservation decision comes back, the starter handles each case:
### ALLOW
The method runs normally.
### ALLOW_WITH_CAPS
The method runs, and a warning is logged. Caps are available through `CyclesContextHolder` for the method to inspect and respect.
### DENY
The method does not run. A `CyclesProtocolException` is thrown with the reason code and optional `retryAfterMs` hint.
The caller can catch this to implement degradation:
```java
try {
return service.summarize(text);
} catch (CyclesProtocolException e) {
if (e.getRetryAfterMs() != null) {
// retry after suggested delay
}
return fallbackResponse();
}
```
## Self-invocation (internal method calls)
Spring's proxy-based AOP **does not intercept internal method calls** within the same class. If a method calls another method in the same bean using `this.method()`, the call bypasses the proxy and the `@Cycles` aspect never fires.
```java
// BROKEN — @Cycles is silently ignored on internal calls
@Service
public class MyService {
public String handleRequest(String input) {
return guardedCall(input); // calls this.guardedCall() — bypasses proxy
}
@Cycles("#input.length() * 10")
public String guardedCall(String input) {
return "Processed: " + input; // @Cycles never activates
}
}
```
### Workaround 1: Extract to a separate bean (recommended)
Move the `@Cycles`-annotated method into its own `@Service` and inject it:
```java
@Service
public class GuardedService {
@Cycles("#input.length() * 10")
public String guardedCall(String input) {
return "Processed: " + input; // @Cycles works — called through proxy
}
}
@Service
public class MyService {
@Autowired
private GuardedService guardedService;
public String handleRequest(String input) {
return guardedService.guardedCall(input);
}
}
```
### Workaround 2: Self-inject the proxy
If extracting a bean is impractical, inject the proxy of your own class using `@Lazy`:
```java
@Service
public class MyService {
@Lazy
@Autowired
private MyService self;
public String handleRequest(String input) {
return self.guardedCall(input); // calls through proxy — @Cycles works
}
@Cycles("#input.length() * 10")
public String guardedCall(String input) {
return "Processed: " + input;
}
}
```
::: tip Startup warning
The starter logs a `WARN` at startup when it detects a bean where some methods have `@Cycles` and others do not, since this pattern is susceptible to self-invocation issues. The warning is informational — it does not block startup.
:::
## Nesting prevention
Calling a `@Cycles`-annotated method from inside another `@Cycles`-annotated method — even across different beans — throws an `IllegalStateException`. This is intentional:
- **Double-counting:** The outer reservation already reserves budget for the full operation. An inner reservation would deduct additional budget from the same pool.
- **Protocol design:** The Cycles Protocol v0 has no concept of parent/child reservations. Each reservation is independent and atomic.
```java
// BROKEN — throws IllegalStateException("Nested @Cycles not supported")
@Service
public class Orchestrator {
@Autowired private LlmService llmService;
@Cycles("#tokens * 10")
public String orchestrate(int tokens) {
return llmService.generate("hello", tokens); // throws!
}
}
@Service
public class LlmService {
@Cycles("#tokens * 5") // ← second @Cycles while outer is active
public String generate(String prompt, int tokens) { ... }
}
```
**Correct pattern:** Place `@Cycles` at the outermost entry point only. Inner services should be plain methods:
```java
@Service
public class Orchestrator {
@Autowired private LlmService llmService;
@Cycles("#tokens * 10")
public String orchestrate(int tokens) {
return llmService.generate("hello", tokens); // works
}
}
@Service
public class LlmService {
// No @Cycles here — called from within an already-guarded operation
public String generate(String prompt, int tokens) { ... }
}
```
## Commit retry
Known actual usage is written to the durable journal before the first commit request. Transient and ambiguous outcomes retry with the original idempotency key; retry exhaustion, authentication failure, and unclassifiable 4xx responses remain queued across JVM restart. If the reservation expired, the starter switches the durable record to a direct event before attempting recovery. A recognized terminal commit rejection stops retry and discards the unrecoverable journal entry, but never releases the reservation after the guarded method has spent the resource.
The retry engine is configurable and extensible. The default `JournaledCommitRetryEngine` uses exponential backoff and drains for a bounded time during Spring shutdown.
Custom retry strategies can be provided by implementing the `CommitRetryEngine` interface.
## Lifecycle summary
For each `@Cycles`-annotated method call:
1. Estimate is evaluated (SpEL expression or fixed value)
2. Reservation is created on the Cycles server
3. Decision is checked (ALLOW / ALLOW_WITH_CAPS / DENY)
4. If DENY: throw exception, method does not run
5. Heartbeat extension is scheduled (background thread)
6. Method executes
7. Actual usage is evaluated (SpEL expression or estimate)
8. Commit is sent with actual amount and optional metrics
9. Heartbeat is cancelled
10. If method threw: reservation is released instead of committed
## Summary
The Cycles Spring Boot Starter turns budget enforcement into a single annotation:
- `@Cycles("estimate")` wraps any method in a reserve → execute → commit lifecycle
- SpEL expressions provide dynamic cost estimation
- Heartbeat extensions keep reservations alive for long-running operations
- Caps, metrics, and metadata are accessible through `CyclesContextHolder`
- DENY decisions throw catchable exceptions for degradation handling
- Durable settlement recovery preserves known actual usage across ambiguous outcomes and JVM restarts
This gives Spring applications production-grade budget enforcement with minimal code changes.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
# Getting Started with the Cycles MCP Server
[](https://www.npmjs.com/package/@runcycles/mcp-server)
The Cycles MCP Server gives MCP-compatible agents access to Cycles runtime authority tools: reserve, commit, release, decide, check balance, and record events. Instead of integrating an SDK into your application code, you add the MCP server to your agent's tool configuration and the agent gets direct access to those tools.
This is the fastest way to expose Cycles budget tools to an MCP-compatible AI agent. For hard production enforcement, route costly or risky actions through the reserve → execute → commit/release lifecycle, or enforce Cycles in the application/gateway layer.
::: warning What this does and does not enforce
The MCP server **exposes Cycles tools** to the agent. It does not automatically proxy or block every other MCP tool, API call, or model request — the agent can still take actions that bypass Cycles unless the host requires a live `cycles_reserve` before execution and settles the reservation afterward. `cycles_decide` is a non-locking preflight check.
Use this for:
- budget-aware agents and operator workflows
- explicit reserve / commit / release flows
- demos and local integration
For deterministic production enforcement, make the Cycles check part of the tool execution path itself — at the SDK, gateway, or framework adapter layer.
:::
::: tip Cycles provides three runtime-authority pillars
- **Spend** — `cycles_reserve` / `cycles_commit` / `cycles_release` enforce budget before instrumented agent actions
- **Risky actions** — `cycles_decide` performs a non-locking preflight using caller-assigned `RISK_POINTS` estimates and can return `ALLOW`, `ALLOW_WITH_CAPS`, or `DENY`. The application must apply the decision and any returned caps.
- **Audit** — `cycles_create_event` and reserve-commit lifecycle calls create structured records for export, compliance, attribution, and incident review
:::
## Prerequisites
- **A running Cycles stack** with a tenant, API key, and budget. If you don't have one yet, follow [Deploy the Full Stack](/quickstart/deploying-the-full-cycles-stack) first.
- **Node.js 20+ with `npx` available** for Claude Code, Cursor, Windsurf, or a manual Claude Desktop configuration. The recommended Claude Desktop `.mcpb` extension uses Claude Desktop's bundled runtime and does not require a separate Node.js installation.
::: tip Where do I get my API key?
API keys are created through the **Cycles Admin Server** (port 7979). Use a runtime API key such as `cyc_live_...`. If your stack is already running with a tenant, create one directly:
```bash
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": "mcp-key",
"permissions": ["reservations:create","reservations:commit","reservations:release","reservations:extend","reservations:list","balances:read"]
}' | jq -r '.key_secret'
```
The response returns the full key (e.g. `cyc_live_abc123...`). **Save it — the secret is only shown once.**
The permissions above are the valid runtime permissions used by the MCP tool set. The current permission schema does not define separate `decide` or `events:create` values; including either causes API-key creation to fail validation. For a least-privilege key, omit read or lifecycle permissions for tools the agent does not need.
Need the full setup? See [Deploy the Full Stack — Create an API key](/quickstart/deploying-the-full-cycles-stack#step-3-create-an-api-key). For rotation and lifecycle details, see [API Key Management](/how-to/api-key-management-in-cycles).
:::
## Pick your client
Each client has its own config file path and quirks. Start with the one you use:
| Client | Quickstart |
|---|---|
| **Claude Desktop** | [Add Cycles to Claude Desktop](/quickstart/mcp-claude-desktop) |
| **Claude Code** | [Add Cycles to Claude Code](/quickstart/mcp-claude-code) |
| **Cursor** | [Add Cycles to Cursor](/quickstart/mcp-cursor) |
| **Windsurf** | [Add Cycles to Windsurf](/quickstart/mcp-windsurf) |
| Other MCP-compatible client | Use the STDIO config below as a template |
All hosts use the same `@runcycles/mcp-server` implementation. Claude Desktop can install the bundled `.mcpb` desktop extension; the other local setup paths launch the npm package via `npx`. Config-file paths and client-specific behavior still differ.
### Generic STDIO config (template)
```json
{
"mcpServers": {
"cycles": {
"command": "npx",
"args": ["-y", "@runcycles/mcp-server"],
"env": {
"CYCLES_API_KEY": "cyc_live_...",
"CYCLES_BASE_URL": "http://localhost:7878"
}
}
}
}
```
### Mock mode (no backend required)
To try the server without a running Cycles stack, set `CYCLES_MOCK: "true"` instead of the API key / base URL. Mock mode returns realistic synthetic responses; generated IDs and timestamps vary between calls. It performs no live enforcement. In a production Node environment, the server refuses to start in mock mode unless `CYCLES_ALLOW_MOCK_IN_PRODUCTION` is explicitly set to `"true"`.
```json
{
"mcpServers": {
"cycles": {
"command": "npx",
"args": ["-y", "@runcycles/mcp-server"],
"env": { "CYCLES_MOCK": "true" }
}
}
}
```
### Running the server over HTTP
For a shared remote MCP gateway (multi-developer team, cloud deploy, sidecar in CI), see [Running the MCP server over HTTP](/how-to/running-the-mcp-server-over-http). STDIO is the right default for a single developer on a local machine.
## Your first budget check
Once connected, ask your agent to check a budget balance:
> "Check the budget balance for tenant acme-corp"
The agent will call `cycles_check_balance` with `tenant: "acme-corp"` and return matching balance records — remaining budget, reserved amounts, and total spent. If you need descendant scopes, ask for child scopes explicitly; the tool maps that to `includeChildren: true` where the server supports it.
## The reserve-commit lifecycle
The core pattern is **reserve → execute → commit**. Commit the actual usage whenever execution incurred cost, including an operation that started but later failed. Use **release** only when the reservation was unused — for example, the operation was cancelled, skipped, or failed before execution. Here's how it works through MCP tools:
**Step 1 — Reserve** before doing something expensive:
> "Reserve 500,000 USD_MICROCENTS for an OpenAI GPT-4o call"
The agent calls `cycles_reserve` and gets back a `reservationId` and a decision of `ALLOW` or `ALLOW_WITH_CAPS`. The budget is locked and the agent can proceed, applying any returned caps. If a live reservation is rejected, the tool returns an error such as `BUDGET_EXCEEDED`; `decision: "DENY"` is returned only by `cycles_decide` or a reserve call with `dryRun: true`.
**Step 2 — Execute** the operation (the LLM call, API request, etc.)
**Step 3 — Commit** actual usage:
> "Commit reservation res_abc123 with actual usage 423,100 USD_MICROCENTS"
The agent calls `cycles_commit` with the `reservationId` and the actual amount. The difference between the reserved estimate and the actual usage is returned to the budget pool.
If no execution or billable work occurred, the agent calls `cycles_release` instead to return the full reserved amount. If the operation incurred partial usage before failing, commit that actual usage rather than releasing the reservation.
## Handling decisions
`cycles_decide` and `cycles_reserve` with `dryRun: true` can return any of the three decisions below. A successful live `cycles_reserve` returns `ALLOW` or `ALLOW_WITH_CAPS`; a live denial is surfaced as an MCP tool error carrying the Cycles error code and HTTP status.
| Decision | Meaning | Agent should… |
|----------|---------|---------------|
| `ALLOW` | Budget is available, proceed normally | Execute the operation |
| `ALLOW_WITH_CAPS` | The deepest matching budget has configured caps | Apply the returned constraints before execution. The `caps` field can contain `maxTokens`, `maxStepsRemaining`, `toolAllowlist`, `toolDenylist`, and `cooldownMs` |
| `DENY` | Budget exhausted or insufficient | Stop, inform the user, or switch to a free fallback |
## Available tools
The MCP server exposes 9 tools:
| Tool | Description |
|------|-------------|
| `cycles_reserve` | Reserve budget before a costly operation. Returns a reservation ID and decision |
| `cycles_commit` | Commit actual usage after an operation completes. Records actual usage against the budget |
| `cycles_release` | Release a reservation without committing. Returns budget to the pool |
| `cycles_extend` | Extend the TTL of an active reservation (heartbeat for long-running ops) |
| `cycles_decide` | Lightweight preflight check — ask if an action would be allowed without reserving |
| `cycles_check_balance` | Check current budget balance for a scope |
| `cycles_list_reservations` | List reservations, filtered by status or subject |
| `cycles_get_reservation` | Get details of a specific reservation by ID |
| `cycles_create_event` | Record completed usage directly without a reservation lifecycle. This is post-hoc direct-debit metering, not arbitrary governance-event ingestion or pre-execution enforcement |
## Built-in prompts
The server includes 3 prompts that agents can invoke for guided workflows:
| Prompt | Description |
|--------|-------------|
| `integrate_cycles` | Generate reserve-commit lifecycle patterns for a specific language and use case |
| `diagnose_overrun` | Analyze budget exhaustion — guides through checking balances and listing reservations |
| `design_budget_strategy` | Recommend scope hierarchy, limits, units, and degradation strategy for a workflow |
## Configuration reference
| Variable | Default | Description |
|----------|---------|-------------|
| `CYCLES_API_KEY` | *(required in real mode)* | API key for authenticating with the Cycles server |
| `CYCLES_BASE_URL` | *(required in real mode)* | Base URL of your Cycles server (e.g., `http://localhost:7878`) |
| `CYCLES_MOCK` | — | Set to `"true"` to use mock mode (no server needed) |
| `CYCLES_ALLOW_MOCK_IN_PRODUCTION` | `false` | Must be `"true"` to allow mock mode when `NODE_ENV=production`; use only intentionally because mock mode disables enforcement |
| `CYCLES_DEFAULT_TENANT` | — | Default `subject.tenant` when the caller omits it |
| `CYCLES_DEFAULT_WORKSPACE` | — | Default `subject.workspace` when the caller omits it |
| `CYCLES_DEFAULT_APP` | — | Default `subject.app` when the caller omits it |
| `CYCLES_DEFAULT_WORKFLOW` | — | Default `subject.workflow` when the caller omits it |
| `CYCLES_DEFAULT_AGENT` | — | Default `subject.agent` when the caller omits it |
| `CYCLES_DEFAULT_TOOLSET` | — | Default `subject.toolset` when the caller omits it |
| `PORT` | `3000` | HTTP port when using `--transport http` |
| `HOST` | all interfaces | HTTP bind address; set `127.0.0.1` for loopback-only access |
| `MCP_HTTP_AUTH_TOKEN` | — | Shared bearer token required on every `/mcp` request when configured; `/health` remains public |
Explicit subject fields always override `CYCLES_DEFAULT_*` values, and custom `dimensions` are never defaulted. Defaults apply to `cycles_reserve`, `cycles_decide`, `cycles_create_event`, and `cycles_check_balance`; they do not rewrite existing reservations or reservation-list filters. Blank defaults are ignored, while whitespace-only or over-128-character values fail validation.
Every mutating tool still requires a caller-supplied `idempotencyKey`. Reuse the same key when retrying the same logical operation so Cycles can deduplicate the request. Under budget pressure, tool responses may also append plain-text agent hints after the structured JSON for `DENY`, `ALLOW_WITH_CAPS`, or balances below roughly 15% remaining.
## Next steps
- **[Integrating Cycles with MCP](/how-to/integrating-cycles-with-mcp)** — advanced patterns: preflight decisions, graceful degradation, long-running operations, fire-and-forget events
- **[Running the MCP server over HTTP](/how-to/running-the-mcp-server-over-http)** — when to use HTTP transport, and how to deploy a shared remote MCP gateway
- **[Architecture Overview](/quickstart/architecture-overview-how-cycles-fits-together)** — how the MCP server fits into the full Cycles stack
- **[End-to-End Tutorial](/quickstart/end-to-end-tutorial)** — walk through the complete reserve → commit lifecycle hands-on
- **[Cost Estimation Cheat Sheet](/how-to/cost-estimation-cheat-sheet)** — estimate token costs for popular LLM models
# Getting Started with the Python Client
[](https://pypi.org/project/runcycles/)
The `runcycles` Python package provides both a `@cycles` decorator and a programmatic `CyclesClient` for adding budget enforcement to any Python application.
The decorator wraps any function in a reserve → execute → commit lifecycle:
1. **Before the function runs:** evaluates the estimate, creates a reservation, and checks the decision
2. **While the function runs:** maintains the reservation with automatic heartbeat extensions
3. **After the function returns:** commits actual usage and releases any unused remainder
4. **If the function raises:** releases the reservation to return budget to the pool
Once actual usage is known, the current client persists settlement before the first commit request. Ambiguous outcomes replay with the same key, and an expired commit is recovered through `POST /v1/events`. The guarantee cannot cover a process death before actual usage is known; see [SDK Settlement Recovery and Durability](/protocol/sdk-settlement-recovery-and-durability).
::: tip Cycles provides three runtime-authority pillars
- **Spend** — reserve-commit budget enforcement before instrumented LLM calls and tool actions
- **Risky actions** — callers can budget assigned `RISK_POINTS`; applications must apply preflight decisions and any configured caps
- **Audit** — reservations, commits, releases, and direct-usage events create lifecycle records; non-persisting preflight decisions need application logging
:::
## Prerequisites
You need a running Cycles stack with a tenant, API key, and budget. If you don't have one yet, follow [Deploy the Full Stack](/quickstart/deploying-the-full-cycles-stack) first.
::: tip Where do I get my API key?
API keys are created through the **Cycles Admin Server** (port 7979) and always start with `cyc_live_`. If your stack is already running with a tenant, create one directly:
```bash
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"]
}' | jq -r '.key_secret'
```
The response returns the full key (e.g. `cyc_live_abc123...`). **Save it — the secret is only shown once.**
Need the full setup? See [Deploy the Full Stack — Create an API key](/quickstart/deploying-the-full-cycles-stack#step-3-create-an-api-key). For rotation and lifecycle details, see [API Key Management](/how-to/api-key-management-in-cycles).
:::
## Verify your server is running
Before writing any code, confirm the Cycles Server is reachable:
```bash
curl -sf http://localhost:7878/actuator/health | jq .
```
You should see `{"status":"UP"}`. If this fails, check that the server is running per [Deploy the Full Stack](/quickstart/deploying-the-full-cycles-stack).
::: info Two API key types
Cycles uses two different authentication headers:
- **`X-Admin-API-Key`** — used with the **Admin Server** (port 7979) to manage tenants, budgets, and API keys. This is the bootstrap secret (e.g. `admin-bootstrap-key`).
- **`X-Cycles-API-Key`** — used with the **Cycles Server** (port 7878) for runtime operations (reservations, commits, balances). This is the tenant-scoped key starting with `cyc_live_...`.
The `runcycles` client uses `X-Cycles-API-Key` automatically. You only need `X-Admin-API-Key` when calling the Admin Server directly (e.g. to create tenants or API keys).
:::
## Installation
```bash
pip install runcycles
```
Requires Python 3.10+. Dependencies (`httpx`, `pydantic >= 2.0`) are installed automatically.
## Configuration
```python
from runcycles import CyclesConfig
config = CyclesConfig(
base_url="http://localhost:7878",
api_key="cyc_live_...", # from Admin Server — see tip above
tenant="acme-corp",
)
```
Or from environment variables:
```bash
export CYCLES_BASE_URL=http://localhost:7878
export CYCLES_API_KEY=cyc_live_... # from Admin Server /v1/admin/api-keys response
export CYCLES_TENANT=acme-corp
```
```python
config = CyclesConfig.from_env()
```
## The @cycles decorator
The simplest usage — wrap a function with a fixed estimate:
```python
from runcycles import CyclesClient, cycles, set_default_client
client = CyclesClient(config)
set_default_client(client)
@cycles(estimate=1000) # [!code focus]
def summarize(text: str) -> str:
return call_llm(text)
result = summarize("Hello world")
```
This reserves 1000 USD_MICROCENTS before `summarize()` runs, then commits the same amount afterward.
### Dynamic estimates
The estimate can be a callable that receives the function's arguments:
```python
@cycles(estimate=lambda text, max_tokens: max_tokens * 10) # [!code focus]
def generate(text: str, max_tokens: int) -> str:
return call_llm(text, max_tokens=max_tokens)
```
### Specifying actual usage
By default, the estimate is used as the actual amount at commit time. To calculate actual usage from the return value:
```python
@cycles(
estimate=5000,
actual=lambda result: len(result) * 5, # [!code focus]
)
def chat(prompt: str) -> str:
return call_llm(prompt)
```
### Decorator parameters
| Parameter | Default | Description |
|---|---|---|
| `estimate` | (required) | `int` or callable returning `int`. Estimated amount. |
| `actual` | `None` | `int` or callable receiving the return value. Defaults to estimate. |
| `action_kind` | `None` | Action category (e.g. `"llm.completion"`). `str` or callable. |
| `action_name` | `None` | Action identifier (e.g. `"gpt-4"`). `str` or callable. |
| `action_tags` | `None` | List of tags for filtering/reporting. `list[str]` or callable. |
| `unit` | `USD_MICROCENTS` | Budget unit: `USD_MICROCENTS`, `TOKENS`, `CREDITS`, `RISK_POINTS`. |
| `ttl_ms` | `60000` | Reservation TTL in milliseconds. |
| `grace_period_ms` | `None` | Grace period after TTL expiry. When `None`, server default (5000ms) applies. |
| `overage_policy` | `"ALLOW_IF_AVAILABLE"` | `"REJECT"`, `"ALLOW_IF_AVAILABLE"`, or `"ALLOW_WITH_OVERDRAFT"`. |
| `dry_run` | `False` | If `True`, evaluate without persisting. Function does not execute. |
| `tenant` | `None` | Subject tenant override. `str` or callable. |
| `workspace` | `None` | Subject workspace override. `str` or callable. |
| `app` | `None` | Subject app override. `str` or callable. |
| `workflow` | `None` | Subject workflow override. `str` or callable. |
| `agent` | `None` | Subject agent override. `str` or callable. |
| `toolset` | `None` | Subject toolset override. `str` or callable. |
| `dimensions` | `None` | Custom dimensions dict. `dict[str, str]` or callable. |
| `client` | `None` | Explicit client. Falls back to module default. |
| `use_estimate_if_actual_not_provided` | `True` | If `True` and `actual` is `None`, use estimate as actual at commit. |
### Dynamic subject and action fields
Since 0.4.0, `action_kind`, `action_name`, `action_tags`, the six subject parameters (`tenant`, `workspace`, `app`, `workflow`, `agent`, `toolset`), and `dimensions` also accept a callable. The callable is invoked with the decorated function's `*args, **kwargs` at reservation time, so subject and action can be routed per call:
```python
@cycles(
estimate=1000,
workspace=lambda req, workspace_id: workspace_id, # [!code focus]
action_kind=lambda req, *_: f"llm.{req.provider}", # [!code focus]
action_name=lambda req, *_: req.model, # [!code focus]
)
def run_request(req: Request, workspace_id: str) -> Response:
...
```
A falsy result (e.g. `None`) falls through: subject fields fall back to the config default, `action_kind`/`action_name` fall back to `"unknown"`, and `action_tags`/`dimensions` are omitted from the request.
## Accessing reservation context at runtime
Inside a decorated function, the current reservation context is available via `get_cycles_context()`:
```python :line-numbers
from runcycles import cycles, get_cycles_context, CyclesMetrics
@cycles(estimate=1000)
def process(text: str) -> str:
ctx = get_cycles_context() # [!code focus]
# Check reservation details
print(f"Reservation: {ctx.reservation_id}")
print(f"Decision: {ctx.decision}")
# Check caps (if ALLOW_WITH_CAPS)
if ctx.has_caps():
max_tokens = ctx.caps.max_tokens
if not ctx.caps.is_tool_allowed("web.search"):
pass # skip web search
# Attach metrics for the commit
ctx.metrics = CyclesMetrics(
tokens_input=150,
tokens_output=80,
latency_ms=320,
model_version="gpt-4o-mini",
)
# Attach metadata for audit
ctx.commit_metadata = {"app_request_id": "req-abc-123"}
return call_llm(text)
```
## Decision handling
When the reservation decision comes back, the decorator handles each case:
- **ALLOW** — the function runs normally.
- **ALLOW_WITH_CAPS** — the function runs. Caps are available through `get_cycles_context()` for the function to inspect and respect.
- **DENY** — the function does not run. `CyclesProtocolError` is raised, with `reason_code` set. A specific subclass (e.g. `BudgetExceededError`) is raised only when the server returns a matching HTTP error code; a 200 response with `decision=DENY` raises the plain `CyclesProtocolError`.
```python
from runcycles import BudgetExceededError, CyclesProtocolError
try:
result = summarize("Hello")
except BudgetExceededError: # [!code focus]
result = fallback_response()
except CyclesProtocolError as e:
if e.retry_after_ms:
# retry after suggested delay
pass
result = fallback_response()
```
## Async support
The `@cycles` decorator works with async functions automatically:
```python
from runcycles import AsyncCyclesClient, cycles, set_default_client
async_client = AsyncCyclesClient(config)
set_default_client(async_client)
@cycles(estimate=1000) # [!code focus]
async def async_summarize(text: str) -> str:
return await call_llm_async(text)
result = await async_summarize("Hello")
```
## Programmatic client
For full control, use `CyclesClient` directly:
```python :line-numbers
from runcycles import (
CyclesClient, ReservationCreateRequest, CommitRequest, ReleaseRequest,
Subject, Action, Amount, Unit, CyclesMetrics,
)
with CyclesClient(config) as client:
# 1. Reserve
response = client.create_reservation(ReservationCreateRequest( # [!code focus]
idempotency_key="req-001",
subject=Subject(tenant="acme", agent="support-bot"),
action=Action(kind="llm.completion", name="gpt-4"),
estimate=Amount(unit=Unit.USD_MICROCENTS, amount=500_000),
ttl_ms=30_000,
))
if not response.is_success:
raise RuntimeError(f"Reservation failed: {response.error_message}")
# Defensive: a conformant server returns 409 on live budget denial, but
# dry-run responses (and lenient servers) return 200 with decision=DENY
# and no reservation_id - check before using it
if response.get_body_attribute("decision") == "DENY":
raise RuntimeError(
f"Reservation denied: {response.get_body_attribute('reason_code')}"
)
reservation_id = response.get_body_attribute("reservation_id")
# 2. Execute
try:
result = call_llm("Hello")
# 3. Commit
client.commit_reservation(reservation_id, CommitRequest( # [!code focus]
idempotency_key="commit-001",
actual=Amount(unit=Unit.USD_MICROCENTS, amount=420_000),
metrics=CyclesMetrics(tokens_input=1200, tokens_output=800),
))
except Exception:
# 4. Release on failure
client.release_reservation(reservation_id, ReleaseRequest(
idempotency_key="release-001",
reason="Processing failed",
))
raise
```
### Preflight decision check
```python
from runcycles import DecisionRequest
response = client.decide(DecisionRequest( # [!code focus]
idempotency_key="decide-001",
subject=Subject(tenant="acme"),
action=Action(kind="llm.completion", name="gpt-4"),
estimate=Amount(unit=Unit.USD_MICROCENTS, amount=500_000),
))
decision = response.get_body_attribute("decision") # "ALLOW", "ALLOW_WITH_CAPS", or "DENY"
```
### Querying balances
```python
response = client.get_balances(tenant="acme")
print(response.body)
```
At least one subject filter kwarg (`tenant`, `workspace`, `app`, `workflow`, `agent`, or `toolset`) is required — calling `get_balances()` with none raises `ValueError`.
### Recording events (direct debit)
```python
from runcycles import EventCreateRequest
response = client.create_event(EventCreateRequest( # [!code focus]
idempotency_key="evt-001",
subject=Subject(tenant="acme"),
action=Action(kind="api.call", name="geocode"),
actual=Amount(unit=Unit.USD_MICROCENTS, amount=1_500),
))
```
## Suggested walkthrough
Follow this order to build understanding progressively:
**1. Reserve and commit with a fixed estimate**
```python
from runcycles import CyclesClient, CyclesConfig, cycles, set_default_client
config = CyclesConfig(base_url="http://localhost:7878", api_key="cyc_live_...", tenant="acme-corp")
client = CyclesClient(config)
set_default_client(client)
@cycles(estimate=1000) # [!code focus]
def hello(name: str) -> str:
return f"Hello, {name}!"
result = hello("world")
print(result)
```
**2. Check your balance**
```python
response = client.get_balances(tenant="acme-corp")
print(response.body)
```
**3. Try a dry run**
```python
@cycles(estimate=500, dry_run=True)
def dry_run_func() -> str:
return "This won't consume budget"
dry_run_func()
# Check balances — they haven't changed
```
**4. Use dynamic estimates with metrics**
```python :line-numbers
from runcycles import get_cycles_context, CyclesMetrics
@cycles(
estimate=lambda prompt, max_tokens: max_tokens * 10, # [!code focus]
actual=lambda result: len(result) * 5, # [!code focus]
action_kind="llm.completion",
action_name="gpt-4",
)
def generate(prompt: str, max_tokens: int) -> str:
ctx = get_cycles_context()
ctx.metrics = CyclesMetrics(tokens_input=len(prompt), tokens_output=max_tokens)
return f"Generated response for: {prompt}"
result = generate("Explain budgets", max_tokens=500)
```
**5. Handle denials gracefully**
```python
from runcycles import BudgetExceededError
@cycles(estimate=999_999_999)
def expensive_func() -> str:
return "This needs a lot of budget"
try:
expensive_func()
except BudgetExceededError: # [!code focus]
print("Budget exhausted — using fallback")
```
## Nested `@cycles` calls
Calling a `@cycles`-decorated function from inside another `@cycles`-decorated function is allowed — it will not raise an error. However, each decorator creates an **independent reservation** that deducts budget separately:
```python
@cycles(estimate=100, action_name="inner")
def inner_call():
return "done"
@cycles(estimate=500, action_name="outer")
def outer_call():
return inner_call() # creates a SECOND reservation — 600 total deducted, not 500
```
This means nested decorators **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 `@cycles` at the outermost entry point only. Inner functions should be plain functions without their own guard:
```python
def inner_call(): # no @cycles — called within a guarded operation
return "done"
@cycles(estimate=500, action_name="outer")
def outer_call():
return inner_call() # single reservation — 500 total
```
## Lifecycle summary
For each `@cycles`-decorated function call:
1. Estimate is evaluated (callable or fixed value)
2. Reservation is created on the Cycles server
3. Decision is checked (ALLOW / ALLOW_WITH_CAPS / DENY)
4. If DENY: exception is raised, function does not run
5. Heartbeat extension is scheduled (background thread; asyncio task for async functions)
6. Function executes
7. Actual usage is evaluated (callable, fixed value, or estimate); a failing or invalid callback commits the estimate with `metadata.actual_source=estimate`
8. Commit is sent with actual amount and optional metrics
9. Heartbeat is cancelled
10. If the guarded function raised: reservation is released instead of committed
11. If post-action settlement setup failed: the error surfaces, but known spend is never released
If estimate fallback is disabled without an `actual`, the client rejects the
configuration before creating a reservation or running the function. A
recognized terminal commit rejection stops retry and discards the unrecoverable
journal record without releasing the reservation after spend occurred.
## Next steps
- [Integrating with OpenAI Agents SDK](/how-to/integrating-cycles-with-openai-agents) — budget governance for multi-agent workflows
- [Error Handling in Python](/how-to/error-handling-patterns-in-python) — Python-specific exception hierarchy and patterns
- [Error Handling Patterns](/how-to/error-handling-patterns-in-cycles-client-code) — general error handling patterns
- [API Reference](/api/) — interactive endpoint documentation
- [Using the Client Programmatically](/how-to/using-the-cycles-client-programmatically) — programmatic client reference
# Rust Client Quickstart — Budget Control for AI Agents
[](https://crates.io/crates/runcycles)
Building AI agents in Rust with Tokio? You need hard limits on LLM spending and tool-call exposure **before** they execute, not after. The Cycles Rust client (`runcycles` crate) gives any async Rust application a reserve-commit budget enforcement layer with three integration levels — from a one-line `with_cycles()` wrapper to RAII guards to a low-level programmatic client.
Same wire protocol as the [Python](/quickstart/getting-started-with-the-python-client), [TypeScript](/quickstart/getting-started-with-the-typescript-client), and [Spring Boot](/quickstart/getting-started-with-the-cycles-spring-boot-starter) clients — switch languages without changing your Cycles server.
::: tip Cycles provides three runtime-authority pillars
- **Spend** — reserve-commit budget enforcement before instrumented LLM calls and tool actions
- **Risky actions** — callers can budget assigned `RISK_POINTS`; applications must apply preflight decisions and any configured caps
- **Audit** — reservations, commits, releases, and direct-usage events create lifecycle records; non-persisting preflight decisions need application logging
:::
The `runcycles` crate provides three levels of budget enforcement for any async Rust application:
1. **`with_cycles()`** — automatic reserve → execute → commit/release (like Python's `@cycles` decorator)
2. **`ReservationGuard`** — RAII guard for manual control (streaming, multi-step workflows)
3. **`CyclesClient`** — low-level programmatic API for full control
The two high-level integrations own this lifecycle; the low-level client exposes the same protocol operations for you to compose:
1. **Before the operation:** evaluates the estimate, creates a reservation, and checks the decision
2. **While the operation runs:** maintains the reservation with automatic heartbeat extensions
3. **After the operation returns:** commits actual usage and releases any unused remainder
4. **If the operation fails:** releases the reservation to return budget to the pool
## Prerequisites
You need a running Cycles stack with a tenant, API key, and budget. If you don't have one yet, follow [Deploy the Full Stack](/quickstart/deploying-the-full-cycles-stack) first.
::: tip Where do I get my API key?
API keys are created through the **Cycles Admin Server** (port 7979) and always start with `cyc_live_`. If your stack is already running with a tenant, create one directly:
```bash
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"]
}' | jq -r '.key_secret'
```
The response returns the full key (e.g. `cyc_live_abc123...`). **Save it — the secret is only shown once.**
Need the full setup? See [Deploy the Full Stack — Create an API key](/quickstart/deploying-the-full-cycles-stack#step-3-create-an-api-key). For rotation and lifecycle details, see [API Key Management](/how-to/api-key-management-in-cycles).
:::
## Verify your server is running
Before writing any code, confirm the Cycles Server is reachable:
```bash
curl -sf http://localhost:7878/actuator/health | jq .
```
You should see `{"status":"UP"}`. If this fails, check that the server is running per [Deploy the Full Stack](/quickstart/deploying-the-full-cycles-stack).
::: info Two API key types
Cycles uses two different authentication headers:
- **`X-Admin-API-Key`** — used with the **Admin Server** (port 7979) to manage tenants, budgets, and API keys. This is the bootstrap secret (e.g. `admin-bootstrap-key`).
- **`X-Cycles-API-Key`** — used with the **Cycles Server** (port 7878) for runtime operations (reservations, commits, balances). This is the tenant-scoped key starting with `cyc_live_...`.
The `runcycles` client uses `X-Cycles-API-Key` automatically. You only need `X-Admin-API-Key` when calling the Admin Server directly (e.g. to create tenants or API keys).
:::
## Installation
```bash
cargo add runcycles
```
Or add to `Cargo.toml`:
```toml
[dependencies]
runcycles = "0.3"
tokio = { version = "1", features = ["full"] }
```
Requires Rust 1.88+. Dependencies (`reqwest`, `serde`, `tokio`) are installed automatically.
`ReservationGuard::commit()` persists known actual usage before its first settlement request. Ambiguous outcomes remain queued with the original idempotency key, and expired commits recover through `POST /v1/events`. See [SDK Settlement Recovery and Durability](/protocol/sdk-settlement-recovery-and-durability).
## Configuration
```rust
use runcycles::CyclesClient;
let client = CyclesClient::builder(
"cyc_live_...", // from Admin Server — see tip above
"http://localhost:7878",
)
.tenant("acme-corp")
.build();
```
Or from environment variables:
```bash
export CYCLES_BASE_URL=http://localhost:7878
export CYCLES_API_KEY=cyc_live_... # from Admin Server /v1/admin/api-keys response
export CYCLES_TENANT=acme-corp
```
```rust
let config = CyclesConfig::from_env().expect("missing CYCLES_ env vars");
let client = CyclesClient::new(config);
```
For the complete configuration surface — retry tuning, custom `reqwest::Client`, blocking variant, env var prefix customization — see the [Rust Client Configuration Reference](/configuration/rust-client-configuration-reference).
## Automatic lifecycle with `with_cycles()`
The simplest way to add budget enforcement — wrap any async operation:
```rust
use runcycles::{CyclesClient, with_cycles, WithCyclesConfig, models::*};
let reply = with_cycles( // [!code focus]
&client,
WithCyclesConfig::new(Amount::tokens(1000))
.action("llm.completion", "gpt-4o")
.subject(Subject { tenant: Some("acme-corp".into()), ..Default::default() }),
|ctx| async move { // [!code focus]
// ctx.caps, ctx.decision, ctx.reservation_id available
let response = call_llm("Hello").await;
let actual_tokens = 42; // from your LLM response usage stats
Ok((response, Amount::tokens(actual_tokens))) // [!code focus]
},
).await?;
// On success → auto-commits actual_tokens. On error → auto-releases.
```
The closure returns `Ok((result, actual_cost))` — a tuple of your return value and the actual `Amount` spent. If the closure returns `Err`, the reservation is released automatically.
### `WithCyclesConfig` parameters
| Parameter | Default | Description |
|---|---|---|
| `new(estimate)` | (required) | `Amount` — estimated cost to reserve |
| `.action(kind, name)` | `"unknown"` | Action category and identifier (e.g. `"llm.completion"`, `"gpt-4o"`) |
| `.subject(subject)` | `Default` | Who is spending (tenant, workspace, app, etc.) |
| `.ttl_ms(ms)` | `60000` | Reservation TTL in milliseconds |
| `.grace_period_ms(ms)` | server default | Grace period after TTL expiry |
| `.overage_policy(policy)` | server default | `Reject`, `AllowIfAvailable`, or `AllowWithOverdraft` |
| `.action_tags(tags)` | `None` | Tags for filtering/reporting |
| `.metrics(metrics)` | `None` | Attach observability metrics to the commit |
### Accessing context inside the closure
The closure receives a `GuardContext` with the reservation state:
```rust
|ctx| async move {
// Budget decision
println!("Decision: {:?}", ctx.decision); // Allow or AllowWithCaps
println!("Reservation: {}", ctx.reservation_id);
// Check caps (if ALLOW_WITH_CAPS)
if let Some(caps) = &ctx.caps { // [!code focus]
let max_tokens = caps.max_tokens.unwrap_or(1000); // [!code focus]
if !caps.is_tool_allowed("web_search") {
// skip web search — budget policy restricts it
}
}
let result = call_llm("Hello").await;
Ok((result, Amount::tokens(42)))
}
```
## RAII guard for manual control
For streaming, multi-step workflows, or when you need full control over when to commit:
```rust
use runcycles::{CyclesClient, models::*};
// 1. Reserve
let guard = client.reserve( // [!code focus]
ReservationCreateRequest::builder()
.subject(Subject { tenant: Some("acme-corp".into()), ..Default::default() })
.action(Action::new("llm.completion", "gpt-4o"))
.estimate(Amount::tokens(2000))
.ttl_ms(30_000_u64)
.build()
).await?;
// 2. Check caps
if let Some(caps) = guard.caps() {
println!("Max tokens: {:?}", caps.max_tokens);
}
// 3. Execute (e.g. stream chunks, accumulate tokens)
let mut total_tokens = 0i64;
for chunk in stream_llm("Write a poem").await {
total_tokens += chunk.tokens;
}
// 4. Commit — consumes the guard (double-commit = compile error) // [!code focus]
guard.commit( // [!code focus]
CommitRequest::builder()
.actual(Amount::tokens(total_tokens))
.metrics(CyclesMetrics {
tokens_input: Some(100),
tokens_output: Some(total_tokens - 100),
..Default::default()
})
.build()
).await?;
// guard.commit(...) here would be a COMPILE ERROR
```
### Guard lifecycle
- **`guard.commit(self)`** — consumes the guard, commits actual spend. Compile error to call twice.
- **`guard.release(self, reason)`** — consumes the guard, returns budget. Use on error.
- **`guard.extend(ms)`** — manually extend TTL (normally automatic via heartbeat).
- **Drop without commit/release** — logs a warning and spawns a best-effort release.
## Low-level programmatic client
For full control over individual API calls:
```rust
use runcycles::{CyclesClient, models::*};
// Create reservation
let resp = client.create_reservation( // [!code focus]
&ReservationCreateRequest::builder()
.subject(Subject { tenant: Some("acme-corp".into()), ..Default::default() })
.action(Action::new("llm.completion", "gpt-4o"))
.estimate(Amount::usd_microcents(500_000))
.ttl_ms(30_000_u64)
.build()
).await?;
let reservation_id = resp.reservation_id.unwrap();
// Execute your operation...
// Commit
client.commit_reservation(&reservation_id, // [!code focus]
&CommitRequest::builder()
.actual(Amount::usd_microcents(420_000))
.metrics(CyclesMetrics {
tokens_input: Some(1200),
tokens_output: Some(800),
..Default::default()
})
.build()
).await?;
```
### Preflight decision check
```rust
let resp = client.decide( // [!code focus]
&DecisionRequest::builder()
.subject(Subject { tenant: Some("acme-corp".into()), ..Default::default() })
.action(Action::new("llm.completion", "gpt-4o"))
.estimate(Amount::usd_microcents(500_000))
.build()
).await?;
println!("Decision: {:?}", resp.decision); // Allow, AllowWithCaps, or Deny
```
### Querying balances
```rust
let resp = client.get_balances(&BalanceParams {
tenant: Some("acme-corp".into()),
..Default::default()
}).await?;
for balance in &resp.balances {
println!("{}: {} remaining", balance.scope, balance.remaining.amount);
}
```
### Recording events (direct debit)
```rust
let resp = client.create_event( // [!code focus]
&EventCreateRequest::builder()
.subject(Subject { tenant: Some("acme-corp".into()), ..Default::default() })
.action(Action::new("api.call", "geocode"))
.actual(Amount::usd_microcents(1_500))
.build()
).await?;
```
## Decision handling
When the reservation decision comes back, each API level handles it:
- **`with_cycles()`** — returns `Err(Error::BudgetExceeded)` on DENY. Closure never runs.
- **`ReservationGuard`** — `client.reserve()` returns `Err(Error::BudgetExceeded)` on DENY.
- **Low-level** — `resp.decision` can be checked directly.
```rust
use runcycles::Error;
match client.reserve(/* ... */).await {
Ok(guard) => {
// ALLOW or ALLOW_WITH_CAPS — proceed
guard.commit(/* ... */).await?;
}
Err(Error::BudgetExceeded { message, retry_after, .. }) => { // [!code focus]
println!("Budget exceeded: {message}");
if let Some(delay) = retry_after {
tokio::time::sleep(delay).await;
// retry...
}
}
Err(Error::Api { status, code, .. }) => {
println!("API error ({status}): {code:?}");
}
Err(Error::Transport(e)) => {
println!("Network error (retryable): {e}");
}
Err(e) => {
println!("Other error: {e}");
}
}
```
## Suggested walkthrough
Follow this order to build understanding progressively:
**1. Reserve and commit with `with_cycles()`**
```rust
use runcycles::{CyclesClient, with_cycles, WithCyclesConfig, models::*};
let client = CyclesClient::builder("cyc_live_...", "http://localhost:7878")
.tenant("acme-corp")
.build();
let result = with_cycles( // [!code focus]
&client,
WithCyclesConfig::new(Amount::tokens(1000))
.action("llm.completion", "gpt-4o")
.subject(Subject { tenant: Some("acme-corp".into()), ..Default::default() }),
|_ctx| async move {
Ok(("Hello!".to_string(), Amount::tokens(42)))
},
).await?;
println!("{result}");
```
**2. Check your balance**
```rust
let resp = client.get_balances(&BalanceParams {
tenant: Some("acme-corp".into()),
..Default::default()
}).await?;
println!("{:?}", resp.balances);
```
**3. Try a dry run**
```rust
let resp = client.create_reservation(
&ReservationCreateRequest::builder()
.subject(Subject { tenant: Some("acme-corp".into()), ..Default::default() })
.action(Action::new("llm.completion", "gpt-4o"))
.estimate(Amount::tokens(500))
.dry_run(true) // [!code focus]
.build()
).await?;
println!("Decision: {:?}", resp.decision);
// Check balances — they haven't changed
```
**4. Use the RAII guard with caps**
```rust
let guard = client.reserve(/* ... */).await?;
if guard.is_capped() { // [!code focus]
let caps = guard.caps().unwrap();
println!("Max tokens: {:?}", caps.max_tokens);
}
guard.commit(CommitRequest::builder().actual(Amount::tokens(100)).build()).await?;
```
**5. Handle denials gracefully**
```rust
use runcycles::Error;
match with_cycles(&client, WithCyclesConfig::new(Amount::tokens(999_999_999))
.action("llm.completion", "gpt-4o")
.subject(Subject { tenant: Some("acme-corp".into()), ..Default::default() }),
|_ctx| async move { Ok(("".to_string(), Amount::tokens(0))) },
).await {
Ok(_) => println!("Success"),
Err(Error::BudgetExceeded { message, .. }) => { // [!code focus]
println!("Budget exhausted: {message} — using fallback");
}
Err(e) => println!("Error: {e}"),
}
```
## Lifecycle summary
For each `with_cycles()` call or `ReservationGuard`:
1. Estimate is provided via `Amount`
2. Reservation is created on the Cycles server
3. Decision is checked (ALLOW / ALLOW_WITH_CAPS / DENY)
4. If DENY: `Error::BudgetExceeded` is returned, operation does not run
5. Heartbeat extension is scheduled from server-authoritative `remaining_ttl_ms` when present, with a best-effort fallback for older servers
6. Operation executes
7. On success: known actual usage is journaled, then commit is sent with optional metrics
8. On error: reservation is released to return budget
9. Heartbeat is cancelled
10. If guard is dropped without commit/release: best-effort release via `tokio::spawn`
## Next steps
- [Integrate Cycles with async-openai (Rust)](/how-to/integrating-cycles-with-async-openai) — replaces the `call_llm()` placeholders above with a real OpenAI chat completion, including streaming
- [Rust Client Configuration Reference](/configuration/rust-client-configuration-reference) — full config surface, retry tuning, custom `reqwest::Client`, blocking variant
- [Error Handling in Rust](/how-to/error-handling-patterns-in-rust) — Rust-specific error patterns, retries, RAII safety, graceful degradation
- [Integrating Cycles with Rust](/how-to/integrating-cycles-with-rust) — broader integration patterns (multi-step flows, framework middleware)
- [API Reference](/api/) — interactive endpoint documentation
- [How Reserve-Commit Works](/protocol/how-reserve-commit-works-in-cycles) — the core protocol lifecycle
# Getting Started with the TypeScript Client
[](https://www.npmjs.com/package/runcycles)
The `runcycles` TypeScript package provides a `withCycles` higher-order function, a `reserveForStream` streaming adapter, and a programmatic `CyclesClient` for adding budget enforcement to any Node.js application.
The `withCycles` HOF wraps any async function in a reserve → execute → commit lifecycle:
1. **Before the function runs:** evaluates the estimate, creates a reservation, and checks the decision
2. **While the function runs:** maintains the reservation with automatic heartbeat extensions
3. **After the function returns:** commits actual usage and releases any unused remainder
4. **If the function throws:** releases the reservation to return budget to the pool
Once actual usage is known, the current client persists settlement before the first commit request. Ambiguous outcomes replay with the same key, and an expired commit is recovered through `POST /v1/events`. The guarantee cannot cover a process death before actual usage is known; see [SDK Settlement Recovery and Durability](/protocol/sdk-settlement-recovery-and-durability).
::: tip Cycles provides three runtime-authority pillars
- **Spend** — reserve-commit budget enforcement before instrumented LLM calls and tool actions
- **Risky actions** — callers can budget assigned `RISK_POINTS`; applications must apply preflight decisions and any configured caps
- **Audit** — reservations, commits, releases, and direct-usage events create lifecycle records; non-persisting preflight decisions need application logging
:::
## Prerequisites
You need a running Cycles stack with a tenant, API key, and budget. If you don't have one yet, follow [Deploy the Full Stack](/quickstart/deploying-the-full-cycles-stack) first.
::: tip Where do I get my API key?
API keys are created through the **Cycles Admin Server** (port 7979) and always start with `cyc_live_`. If your stack is already running with a tenant, create one directly:
```bash
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"]
}' | jq -r '.key_secret'
```
The response returns the full key (e.g. `cyc_live_abc123...`). **Save it — the secret is only shown once.**
Need the full setup? See [Deploy the Full Stack — Create an API key](/quickstart/deploying-the-full-cycles-stack#step-3-create-an-api-key). For rotation and lifecycle details, see [API Key Management](/how-to/api-key-management-in-cycles).
:::
## Verify your server is running
Before writing any code, confirm the Cycles Server is reachable:
```bash
curl -sf http://localhost:7878/actuator/health | jq .
```
You should see `{"status":"UP"}`. If this fails, check that the server is running per [Deploy the Full Stack](/quickstart/deploying-the-full-cycles-stack).
::: info Two API key types
Cycles uses two different authentication headers:
- **`X-Admin-API-Key`** — used with the **Admin Server** (port 7979) to manage tenants, budgets, and API keys. This is the bootstrap secret (e.g. `admin-bootstrap-key`).
- **`X-Cycles-API-Key`** — used with the **Cycles Server** (port 7878) for runtime operations (reservations, commits, balances). This is the tenant-scoped key starting with `cyc_live_...`.
The `runcycles` client uses `X-Cycles-API-Key` automatically. You only need `X-Admin-API-Key` when calling the Admin Server directly (e.g. to create tenants or API keys).
:::
## Installation
```bash
npm install runcycles
```
Requires Node.js 20+. TypeScript 5+ is recommended but optional — the package works with plain JavaScript. Zero runtime dependencies (uses built-in `fetch` and `AsyncLocalStorage`).
## Configuration
```typescript
import { CyclesConfig } from "runcycles";
const config = new CyclesConfig({
baseUrl: "http://localhost:7878",
apiKey: "cyc_live_...", // from Admin Server — see tip above
tenant: "acme-corp",
});
```
Or from environment variables:
```bash
export CYCLES_BASE_URL=http://localhost:7878
export CYCLES_API_KEY=cyc_live_... # from Admin Server /v1/admin/api-keys response
export CYCLES_TENANT=acme-corp
```
```typescript
const config = CyclesConfig.fromEnv();
```
## The withCycles higher-order function
The simplest usage — wrap an async function with a fixed estimate:
```typescript :line-numbers
import { CyclesClient, CyclesConfig, withCycles, setDefaultClient } from "runcycles";
const config = new CyclesConfig({
baseUrl: "http://localhost:7878",
apiKey: "cyc_live_...",
tenant: "acme-corp",
});
const client = new CyclesClient(config);
setDefaultClient(client);
const summarize = withCycles( // [!code focus]
{ estimate: 1000 }, // [!code focus]
async (text: string) => {
return await callLlm(text);
},
);
const result = await summarize("Hello world");
```
This reserves 1000 USD_MICROCENTS before `summarize()` runs, then commits the same amount afterward.
### Dynamic estimates
The estimate can be a function that receives the wrapped function's arguments:
```typescript
const generate = withCycles(
{ estimate: (text: string, maxTokens: number) => maxTokens * 10 }, // [!code focus]
async (text: string, maxTokens: number) => {
return await callLlm(text, maxTokens);
},
);
```
### Specifying actual usage
By default, the estimate is used as the actual amount at commit time. To calculate actual usage from the return value:
```typescript
const chat = withCycles(
{
estimate: 5000,
actual: (result: string) => result.length * 5, // [!code focus]
},
async (prompt: string) => {
return await callLlm(prompt);
},
);
```
### withCycles parameters
| Parameter | Default | Description |
|---|---|---|
| `estimate` | (required) | `number` or function returning `number`. Estimated amount. |
| `actual` | `undefined` | `number` or function receiving the return value. Defaults to estimate. |
| `actionKind` | `"unknown"` | Action category (e.g. `"llm.completion"`). |
| `actionName` | `"unknown"` | Action identifier (e.g. `"gpt-4"`). |
| `actionTags` | `undefined` | Array of tags for filtering/reporting. |
| `unit` | `"USD_MICROCENTS"` | Budget unit: `"USD_MICROCENTS"`, `"TOKENS"`, `"CREDITS"`, `"RISK_POINTS"`. |
| `ttlMs` | `60000` | Reservation TTL in milliseconds (range: 1000–86400000). |
| `gracePeriodMs` | `undefined` | Grace period after TTL expiry (range: 0–60000). |
| `overagePolicy` | `"ALLOW_IF_AVAILABLE"` | `"REJECT"`, `"ALLOW_IF_AVAILABLE"`, or `"ALLOW_WITH_OVERDRAFT"`. |
| `dryRun` | `false` | If `true`, evaluate without persisting. Function does not execute. |
| `tenant` | `undefined` | Subject tenant override. |
| `workspace` | `undefined` | Subject workspace override. |
| `app` | `undefined` | Subject app override. |
| `workflow` | `undefined` | Subject workflow override. |
| `agent` | `undefined` | Subject agent override. |
| `toolset` | `undefined` | Subject toolset override. |
| `dimensions` | `undefined` | Custom dimensions object. |
| `client` | `undefined` | Explicit client. Falls back to module default. |
| `useEstimateIfActualNotProvided` | `true` | If `true` and `actual` is not set, use estimate as actual at commit. |
Since 0.3.0, `actionKind`, `actionName`, and the six subject fields (`tenant`, `workspace`, `app`, `workflow`, `agent`, `toolset`) accept `string | ((...args) => string | undefined)` — a callable is resolved per call from the wrapped function's arguments.
## Accessing reservation context at runtime
Inside a `withCycles`-guarded function, the current reservation context is available via `getCyclesContext()`:
```typescript :line-numbers
import { withCycles, getCyclesContext } from "runcycles";
const process = withCycles(
{ estimate: 1000, client },
async (text: string) => {
const ctx = getCyclesContext(); // [!code focus]
// Check reservation details
console.log(`Reservation: ${ctx?.reservationId}`);
console.log(`Decision: ${ctx?.decision}`);
// Check caps (if ALLOW_WITH_CAPS)
if (ctx?.caps) {
const maxTokens = ctx.caps.maxTokens;
// Adjust behavior based on caps
}
// Attach metrics for the commit
if (ctx) {
ctx.metrics = {
tokensInput: 150,
tokensOutput: 80,
latencyMs: 320,
modelVersion: "gpt-4o-mini",
};
// Attach metadata for audit
ctx.commitMetadata = { requestId: "req-abc-123" };
}
return await callLlm(text);
},
);
```
The context uses `AsyncLocalStorage`, so it is available in any nested async call within the guarded function.
## Decision handling
When the reservation decision comes back, the HOF handles each case:
- **ALLOW** — the function runs normally.
- **ALLOW_WITH_CAPS** — the function runs. Caps are available through `getCyclesContext()` for the function to inspect and respect.
- **DENY** — the function does not run. A `BudgetExceededError` (or appropriate subclass) is raised.
```typescript :line-numbers
import { BudgetExceededError, CyclesProtocolError } from "runcycles";
try {
const result = await summarize("Hello");
} catch (err) {
if (err instanceof BudgetExceededError) { // [!code focus]
result = fallbackResponse();
} else if (err instanceof CyclesProtocolError) {
if (err.retryAfterMs) {
// retry after suggested delay
}
result = fallbackResponse();
}
}
```
### Exception hierarchy
| Exception | When |
|-----------|------|
| `CyclesError` | Base for all Cycles errors |
| `CyclesProtocolError` | Server returned a protocol-level error |
| `BudgetExceededError` | Budget insufficient for the reservation |
| `OverdraftLimitExceededError` | Debt exceeds the overdraft limit |
| `DebtOutstandingError` | Outstanding debt blocks new reservations (when no overdraft limit configured) |
| `ReservationExpiredError` | Operating on an expired reservation |
| `ReservationFinalizedError` | Operating on an already-committed/released reservation |
| `CyclesTransportError` | Network-level failure (connection, DNS, timeout). Exported for user code — the SDK itself surfaces transport failures from `withCycles`/`reserveForStream` as `CyclesProtocolError` with `status: -1` |
## Streaming support
For LLM streaming where usage is only known after the stream finishes, use `reserveForStream`:
```typescript :line-numbers
import { openai } from "@ai-sdk/openai";
import { consumeStream, streamText } from "ai";
import { CyclesClient, CyclesConfig, reserveForStream } from "runcycles";
const config = new CyclesConfig({
baseUrl: "http://localhost:7878",
apiKey: "cyc_live_...",
tenant: "acme",
});
const client = new CyclesClient(config);
export async function POST(request: Request) {
const { messages } = await request.json();
const estimate = 5000;
const handle = await reserveForStream({ // [!code focus]
client,
estimate,
actionKind: "llm.completion",
actionName: "gpt-4o",
});
const settleEstimate = async (reason: string) => {
if (handle.finalized) return;
try {
await handle.commit(estimate, undefined, {
actual_source: "estimate",
recovery_reason: reason,
});
} catch (settlementError) {
console.error("Cycles settlement failed", settlementError);
}
};
let result;
try {
result = streamText({
model: openai("gpt-4o"),
messages,
abortSignal: request.signal,
onFinish: async ({ totalUsage }) => {
const input = totalUsage.inputTokens ?? 0;
const output = totalUsage.outputTokens ?? 0;
try {
await handle.commit((input + output) * 3, { // [!code focus]
tokensInput: input,
tokensOutput: output,
});
} catch (settlementError) {
// Known spend is already journaled before a strict commit error surfaces.
console.error("Cycles settlement failed", settlementError);
}
},
onError: async ({ error }) => {
console.error(error);
await settleEstimate("stream_error");
},
onAbort: async () => settleEstimate("stream_aborted"),
});
} catch (error) {
// Synchronous setup failed before streamText dispatched the provider call.
await handle.release("stream_startup_failed");
throw error;
}
return result.toUIMessageStreamResponse({ consumeSseStream: consumeStream });
}
```
The handle is once-only and race-safe: `commit()` throws if already finalized
(so bugs are never silently hidden), while `release()` is a silent no-op if
already finalized. `onError` and `onAbort` conservatively commit the estimate
because the provider may already have produced billable partial output; release
is limited to synchronous setup failure before dispatch. Pass `consumeStream`
to preserve the AI SDK's abort callback path. In v0.4.3+, invalid actuals fall
back to the estimate with `metadata.actual_source=estimate`, and a recognized
terminal commit rejection leaves the handle finalized so broad cleanup cannot
return known-spend budget.
### 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 and usage is known only after the stream finishes |
| `CyclesClient` | You need full control over the reservation lifecycle, or are building custom integrations |
## Programmatic client
For full control, use `CyclesClient` directly. The client operates on wire-format (snake_case) JSON. Use typed mappers for camelCase convenience, or pass raw snake_case objects:
```typescript :line-numbers
import {
CyclesClient,
CyclesConfig,
reservationCreateRequestToWire,
reservationCreateResponseFromWire,
commitRequestToWire,
releaseRequestToWire,
} from "runcycles";
const config = new CyclesConfig({
baseUrl: "http://localhost:7878",
apiKey: "cyc_live_...",
});
const client = new CyclesClient(config);
// 1. Reserve
const response = await client.createReservation( // [!code focus]
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) {
throw new Error(`Reservation failed: ${response.errorMessage}`);
}
const parsed = reservationCreateResponseFromWire(response.body!);
// 2. Execute
try {
const result = await callLlm("Hello");
// 3. Commit
await client.commitReservation( // [!code focus]
parsed.reservationId!,
commitRequestToWire({
idempotencyKey: "commit-001",
actual: { unit: "USD_MICROCENTS", amount: 420_000 },
metrics: { tokensInput: 1200, tokensOutput: 800 },
}),
);
} catch (err) {
// 4. Release on failure
await client.releaseReservation(
parsed.reservationId!,
releaseRequestToWire({
idempotencyKey: "release-001",
reason: "Processing failed",
}),
);
throw err;
}
```
You can also pass raw snake_case objects directly without mappers:
```typescript
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,
});
```
### Preflight decision check
```typescript :line-numbers
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"
}
```
### Querying balances
```typescript
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}`);
}
}
```
### Recording events (direct debit)
```typescript
import { eventCreateRequestToWire } from "runcycles";
const response = await client.createEvent( // [!code focus]
eventCreateRequestToWire({
idempotencyKey: "evt-001",
subject: { tenant: "acme" },
action: { kind: "api.call", name: "geocode" },
actual: { unit: "USD_MICROCENTS", amount: 1_500 },
}),
);
```
## Suggested walkthrough
Follow this order to build understanding progressively:
**1. Reserve and commit with a fixed estimate**
```typescript :line-numbers
import { CyclesClient, CyclesConfig, withCycles, setDefaultClient } from "runcycles";
const config = new CyclesConfig({
baseUrl: "http://localhost:7878",
apiKey: "cyc_live_...",
tenant: "acme-corp",
});
const client = new CyclesClient(config);
setDefaultClient(client);
const hello = withCycles( // [!code focus]
{ estimate: 1000 }, // [!code focus]
async (name: string) => `Hello, ${name}!`,
);
const result = await hello("world");
console.log(result);
```
**2. Check your balance**
```typescript
import { balanceResponseFromWire } from "runcycles";
const response = await client.getBalances({ tenant: "acme-corp" });
if (response.isSuccess) {
console.log(balanceResponseFromWire(response.body!));
}
```
**3. Try a dry run**
```typescript
const dryRunFunc = withCycles(
{ estimate: 500, dryRun: true },
async () => "This won't consume budget",
);
await dryRunFunc();
// Check balances — they haven't changed
```
**4. Use dynamic estimates with metrics**
```typescript :line-numbers
import { getCyclesContext } from "runcycles";
const generate = withCycles(
{
estimate: (prompt: string, maxTokens: number) => maxTokens * 10, // [!code focus]
actual: (result: string) => result.length * 5, // [!code focus]
actionKind: "llm.completion",
actionName: "gpt-4",
},
async (prompt: string, maxTokens: number) => {
const ctx = getCyclesContext();
if (ctx) {
ctx.metrics = { tokensInput: prompt.length, tokensOutput: maxTokens };
}
return `Generated response for: ${prompt}`;
},
);
const result = await generate("Explain budgets", 500);
```
**5. Handle denials gracefully**
```typescript :line-numbers
import { BudgetExceededError } from "runcycles";
const expensiveFunc = withCycles(
{ estimate: 999_999_999 },
async () => "This needs a lot of budget",
);
try {
await expensiveFunc();
} catch (err) {
if (err instanceof BudgetExceededError) { // [!code focus]
console.log("Budget exhausted — using fallback");
}
}
```
## 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:
```typescript
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:
```typescript
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
});
```
## Lifecycle summary
For each `withCycles`-guarded function call:
1. Estimate is evaluated (function or fixed value)
2. Reservation is created on the Cycles server
3. Decision is checked (ALLOW / ALLOW_WITH_CAPS / DENY)
4. If DENY: exception is thrown, function does not run
5. Heartbeat extension is scheduled from server-authoritative `remaining_ttl_ms` when available, with a bounded fallback for older servers
6. Function executes
7. Actual usage is evaluated (function, fixed value, or estimate)
8. Known actual usage is durably journaled, then commit is sent with the original idempotency key and optional metrics
9. Heartbeat is stopped
10. If commit remains ambiguous, the record stays queued for same-key replay; if the reservation expired, recovery switches to `POST /v1/events`
11. If function threw: reservation is released instead of committed
## Next steps
- [TypeScript Client Configuration Reference](/configuration/typescript-client-configuration-reference) — all config options and environment variables
- [Error Handling in TypeScript](/how-to/error-handling-patterns-in-typescript) — exception hierarchy, Express/Next.js patterns
- [Testing with Cycles](/how-to/testing-with-cycles) — unit and integration testing patterns
- [Using the Client Programmatically](/how-to/using-the-cycles-client-programmatically) — programmatic client reference
- [Error Handling Patterns](/how-to/error-handling-patterns-in-cycles-client-code) — general error handling patterns across all languages
- [API Reference](/api/) — interactive endpoint documentation
# How to Add Hard Budget Limits to Spring AI with Cycles
Most AI applications start with observability.
You log model usage.
You watch provider dashboards.
You add alerts for abnormal spend.
You maybe enforce a few request-level limits.
That is useful.
But once your system starts running autonomous workflows, tool-calling loops, retries, or multi-step agent behavior, observability alone stops being enough.
At that point, you need a control layer that can decide **before execution** whether work is allowed to proceed.
That is where Cycles fits.
::: tip Cycles provides three runtime-authority pillars
- **Spend** — reserve-commit budget enforcement before instrumented LLM calls and tool actions
- **Risky actions** — callers can budget assigned `RISK_POINTS`; applications must apply preflight decisions and any configured caps
- **Audit** — reservations, commits, releases, and direct-usage events create lifecycle records; non-persisting preflight decisions need application logging
:::
## The problem
In a simple application, one request often maps to one model call.
In a real Spring AI system, one user action can become:
- multiple LLM calls
- retrieval steps
- tool invocations
- retries on transient failure
- multi-step planning
- background follow-up work
A provider dashboard can show this after the fact.
A rate limiter can slow it down.
Neither one guarantees that the workflow stays inside a hard budget boundary.
## What Cycles adds
Cycles adds a deterministic budget-control pattern around autonomous work:
1. **Reserve exposure before execution**
2. **Execute the model or tool call**
3. **Commit actual usage or release the remainder**
This turns budget enforcement into part of the runtime path, instead of a reporting function that happens later.
In a Spring AI application, that usually means guarding:
- model invocations
- tool-calling steps
- agent loop iterations
- workflow branches
- high-cost external actions
## The mental model
Think of Cycles as a **runtime authority for autonomous agents**.
Spring AI handles prompting, model interaction, retrieval, and orchestration.
Cycles handles:
- whether an action is allowed to proceed
- how much budget is reserved for it
- how actual usage is reconciled
- how limits apply across scopes such as tenant, workspace, app, workflow, or agent
The goal is not to replace Spring AI.
The goal is to add hard budget control to it.
## Where to integrate in a Spring AI application
There are several natural integration points.
### 1. Before a model call
Before invoking a chat model or completion model, reserve budget for the expected exposure.
This is the cleanest and most common place to start.
### 2. Before a tool invocation
If tools can create meaningful cost or side effects, reserve budget before the tool runs.
This matters for:
- external APIs
- search services
- database writes
- email dispatch
- ticket creation
- payment actions
### 3. Around an agent loop iteration
If your application runs iterative planning or autonomous loops, reserve budget per step or per iteration.
That gives you a bounded envelope around recursive behavior.
### 4. Around an entire workflow or run
You can also reserve and track at a higher scope:
- per tenant
- per workspace
- per app
- per workflow
- per agent
In practice, many systems use more than one level.
For example:
- tenant daily budget
- workflow execution budget
- model-call budget per step
## A simple integration flow
At a high level, the application flow looks like this:
### Step 1: Identify the scope
Determine which budget scopes apply.
Examples:
- tenant: `acme`
- app: `support-bot`
- workflow: `refund-assistant`
### Step 2: Estimate required exposure
Before calling the model or tool, estimate how much budget the step may need.
This does not need to be perfect.
It just needs to be sufficient to reserve bounded room to act.
### Step 3: Reserve budget
Call Cycles to reserve budget for the step.
If reservation succeeds, continue.
If reservation fails, decide how to degrade:
- stop the action
- return a fallback response
- switch to a smaller model
- skip expensive tools
- move to a lower-cost workflow path
### Step 4: Execute the step
Run the Spring AI call, tool invocation, or workflow action.
### Step 5: Commit actual usage
Once actual usage is known, commit the real amount consumed. If the actual amount is less than the reserved estimate, the unused remainder is released automatically.
### Step 6: Release if canceled
If the work is canceled or fails before producing any usage, release the reservation explicitly to return the reserved amount to the budget pool.
## Example pattern
A simplified application flow might look like this:
1. user asks a question
2. app selects tenant and workflow scope
3. app reserves 100 units before invoking the chat model
4. Spring AI executes the model call
5. actual usage comes back as 68 units
6. app commits 68 (remaining 32 is released automatically)
If the next step wants to invoke an external tool, it goes through the same pattern again.
This is how hard budget boundaries become part of runtime execution.
## Why this works better than post-hoc limits
Many teams already have some form of usage tracking.
That is not the same as pre-execution budget control.
Post-hoc tracking tells you:
::: info
what happened after the work completed
:::
Cycles tells you:
::: info
whether the submitted estimate fits the matching budget ledgers, how much room remains, and what the instrumented work commits afterward
:::
Application authorization still decides whether the caller may perform the action; Cycles accounts for the submitted exposure.
That distinction becomes critical in long-running or multi-step systems.
Without it, you are often reacting after the expensive part has already happened.
## A common first use case
One of the best first integrations is:
**guard every Spring AI model call with a Cycles reservation**
Why start there?
Because it gives you immediate value with minimal architecture change. With [`cycles-spring-ai-starter`](https://github.com/runcycles/cycles-spring-ai-starter), this is literally a Maven dependency plus a few `cycles.spring-ai.*` properties — the `CallAdvisor` and `StreamAdvisor` auto-wire onto every `ChatClient` you build through the auto-configured `ChatClient.Builder`. No call-site changes.
You can begin by enforcing:
- per-tenant budget (set the tenant on `CyclesProperties`, or supply a `SubjectResolver` bean that pulls tenant from your authenticated principal)
- per-workflow budget
- optional per-run envelope by resolving that run ID into `subjects.workflow`
`run` is not a separate Cycles scope. The standard hierarchy is `tenant → workspace → app → workflow → agent → toolset`; a run ID stored only in `dimensions` is attribution and does not create an enforceable ledger.
Then expand to:
- tool invocations (opt in with `cyclesToolGate.wrap(myTool)`)
- retrieval steps
- external side-effecting actions
This staged rollout works well because you do not need to boil the ocean on day one.
## Shadow evaluation before enabling the advisors
The current `cycles-spring-ai-starter` advisors implement the live reserve → model call → commit/release lifecycle. They do not expose a shadow-mode property: a Cycles dry-run response deliberately has no `reservation_id`, while the advisor requires one before it invokes the model.
To evaluate policy before enabling the advisors, make an explicit Cycles reservation request with `dry_run: true` alongside the existing model path, then log the hypothetical decision and the model's actual usage in your application. Dry runs create no reservation, balance mutation, or commit; the current server does emit `reservation.denied` for denied evaluations, but not a complete record of allowed decisions and outcomes. Once those results meet your cutover criteria, enable the starter for live enforcement. See the [shadow-mode rollout guide](/how-to/shadow-mode-in-cycles-how-to-roll-out-budget-enforcement-without-breaking-production).
## Handling failure correctly
A real integration must handle more than the happy path.
That includes:
- retries
- worker crashes
- partial completion
- timeouts
- duplicate requests
This is why reserve, commit, and release are separate lifecycle events.
The application should not assume execution is always synchronous or clean.
A production integration should be designed so that:
- retries are idempotent
- duplicate actions do not double-spend
- incomplete work can be reconciled
- unused reservations do not leak forever
That is where Cycles adds real operational value beyond simple counters or provider dashboards.
## What to budget first
If you are integrating Cycles into Spring AI for the first time, start with the highest-value, easiest-to-measure boundaries.
A good initial rollout is:
- model calls
- tool invocations with external cost
- tenant-level daily or monthly budgets
- per-workflow execution envelopes
Do not start by trying to model every possible action in your system.
Start with the actions most likely to create budget surprises.
## Good first policies
Examples of useful first policies include:
- hard cap per tenant
- hard cap per workflow, with a run ID mapped to `subjects.workflow` when each execution needs its own ledger
- application-side dry-run evaluation before enabling the advisors on new workflows
- downgrade path when reservation fails
- application-configured tool fallback or denial handling
- per-workspace limits for staging vs production
These are practical controls that map well to real incidents.
## Why this matters for Spring AI teams
Spring AI makes it easier to build AI applications on the JVM.
As those applications become more autonomous, they need a way to bound total exposure, not just log it.
That is the role Cycles plays.
It brings:
- pre-execution budget checks
- retry-safe enforcement
- multi-scope budget enforcement
- live advisor enforcement after separate dry-run calibration
- a clean reserve → commit / release lifecycle
In other words, it gives Spring AI applications a way to move from “watching usage” to **governing execution**.
## Practical rollout plan
A simple rollout path looks like this:
### Phase 1: Observe
Issue explicit `dry_run: true` evaluations beside the existing model calls and retain the responses and actual usage in application telemetry.
### Phase 2: Guard core model usage
Add reservation and commit around the most expensive model calls.
### Phase 3: Expand to tools
Guard tool invocations and side-effecting actions.
### Phase 4: Add hierarchical budgets
Apply policies at tenant, app, and workflow scopes. Map a run ID to the workflow subject only when you intentionally want a separate ledger per execution.
### Phase 5: Enforce degradation paths
When reservations fail, downgrade or reroute instead of simply crashing.
That sequence keeps adoption manageable.
## Summary
If you are building with Spring AI, budget enforcement should not live only in dashboards, billing pages, or after-the-fact alerts.
It should be part of the execution path.
Cycles makes that possible by introducing a deterministic runtime pattern:
- reserve before execution
- commit actual usage afterward (unused remainder is released automatically)
- release explicitly if work is canceled
- enforce policy across scopes
- stay safe under retries and concurrency
That is how Spring AI systems move from useful prototypes to governed production runtimes.
## Next steps
For the Spring AI integration specifically:
- **Start here:** [Integrating Cycles with Spring AI](/how-to/integrating-cycles-with-spring-ai) — concrete Maven/Gradle setup, configuration reference, code examples for the auto-wired chat advisor, per-tool gating, observation convention, and the v0.3.0 extension points (`SubjectResolver` for per-request attribution, `PromptTokenEstimator` for real BPE token counts).
- **Starter on GitHub:** [`cycles-spring-ai-starter`](https://github.com/runcycles/cycles-spring-ai-starter) — Spring AI-specific advisors auto-wired onto every `ChatClient`. Companion to `cycles-spring-boot-starter` (used for non-Spring-AI Spring Boot code paths via the `@Cycles` annotation).
To explore the broader Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
# How to Choose a First Cycles Rollout: Tenant Budgets, Run Budgets, or Model-Call Guardrails?
One of the first questions teams ask after understanding Cycles is:
**Where should we start?**
That is the right question.
A good first rollout is not the most complete rollout.
It is the rollout that gives meaningful control quickly, with the least operational friction.
Most teams do not need to model every scope, every workflow, and every action on day one.
They need one of three practical starting points:
- **tenant budgets**
- **run budgets**
- **model-call guardrails**
Each is valid.
Each solves a different problem.
The best choice depends on the failure mode you are trying to prevent first.
::: warning “Run budget” is an integration pattern
Cycles has no native `run` scope. This guide uses **run budget** to mean a standard `workflow` ledger whose `subjects.workflow` value is the run ID. Every protected action in that execution must submit the same workflow subject for the shared envelope to apply. A run ID stored only in `dimensions` adds attribution, not enforcement.
The ledger bounds only instrumented actions and the exposure the application submits. The host must handle a denial by stopping or degrading the run, and must authorize tools and side effects separately.
:::
::: tip Cycles provides three runtime-authority pillars
- **Spend** — reserve-commit budget enforcement before instrumented LLM calls and tool actions
- **Risky actions** — callers can budget assigned `RISK_POINTS`; applications must apply preflight decisions and any configured caps
- **Audit** — live operations create their applicable reservation, balance, and audit records; successful reserve/commit/release/direct-debit paths do not each emit a current runtime Event, and non-persisting preflight decisions need application logging
All three rollouts on this page create the applicable live reservation, balance, and audit records when they use reservations and settlement. Dry-run and `decide` create no reservation or balance mutation; the current server emits `reservation.denied` for denied evaluations, but the application must log all responses and external outcomes for a complete record. Tenant budgets and run budgets primarily address spend; run budgets also bound risky agent loops; model-call guardrails are the lowest-friction way to start with per-call LLM spend enforcement.
:::
## The wrong way to start
A common mistake is to begin with a fully generalized policy hierarchy:
- tenant
- workspace
- app
- workflow
- agent
- toolset
- action type
- model class
- side-effect category
That may be a good end state.
It is usually a bad starting point.
Why?
Because early adoption succeeds when policy is:
- easy to explain
- easy to observe
- tied to real incidents
- narrow enough to tune
- valuable enough to justify operational change
The first Cycles rollout should solve a visible problem, not express the entire ontology of your platform.
## The three best starting points
Most first rollouts should begin with one of these:
### 1. Tenant budgets
Start here if your first priority is **platform economics and customer isolation**.
### 2. Run budgets
Start here if your first priority is **preventing runaway execution**.
### 3. Model-call guardrails
Start here if your first priority is **getting a minimal, low-friction integration into production**.
These are not competing strategies forever.
They are different first wedges.
## Option 1: Start with tenant budgets
Tenant budgets are usually the best first rollout when the core concern is:
- one customer consuming too much
- lack of multi-tenant isolation
- weak plan enforcement
- surprise provider bills
- no hard per-account usage boundary
A tenant budget answers:
::: info
How much total exposure is this customer allowed to create?
:::
That makes it a strong economic and operational starting point.
### Why tenant budgets are attractive
Tenant budgets are easy to explain.
You can say:
- each tenant gets a daily, weekly, or monthly envelope
- all instrumented actions submitted against that ledger and unit count against the envelope
- once exhausted, certain actions stop, downgrade, or defer
This is intuitive for operators, finance, product, and customer-facing teams.
### What tenant budgets solve well
Tenant budgets are strong at:
- hard customer-level limits
- usage isolation
- paid plan enforcement
- predictable top-level exposure boundaries
- straightforward dashboarding and reporting
### What tenant budgets do not solve alone
They do not fully protect against:
- a single runaway run inside a healthy tenant
- workflow-specific over-consumption
- repeated tool loops in one execution
- noisy local failure modes that stay under tenant ceilings
So tenant budgets are often a strong commercial first step, but not always the strongest operational safety step.
### Choose tenant budgets first if:
- you are multi-tenant
- spend isolation is the immediate pain
- you need clear account-level boundaries
- customer over-consumption is more urgent than runaway loops
- you want the easiest policy story for internal stakeholders
## Option 2: Start with run budgets
Run budgets are usually the best first rollout when the core concern is:
- loops
- recursive tool use
- retry storms
- agent over-execution
- long-running workflows that drift out of bounds
A run budget answers:
::: info
How much exposure can this individual execution consume before it must stop or degrade?
:::
That makes it the strongest first step for many autonomous systems.
### Why run budgets are attractive
Run budgets map directly to the incident that usually forces teams to care:
- one agent got stuck
- one workflow kept retrying
- one process used tools too many times
- one background task consumed far more than intended
Run budgets are where “bounded execution” becomes real.
### What run budgets solve well
With consistent instrumentation and host-side denial handling, run budgets are strong at:
- denying further budgeted actions in runaway loops once the ledger lacks room
- limiting recursive tool chains
- bounding one workflow execution
- protecting against local over-consumption
- creating clear envelopes around autonomous behavior
### What run budgets do not solve alone
They do not fully protect against:
- aggregate tenant overuse across many healthy runs
- plan-level commercial limits
- one customer launching many runs in parallel
- uneven cost distribution across workflow types
So run budgets are often the strongest operational safety wedge, but not the full economic model.
### Choose run budgets first if:
- your biggest fear is runaway execution
- you have already seen loops, retries, or recursive tool behavior
- you want the fastest path to bounded autonomy
- you care more about local incident prevention than plan enforcement
- your system is agentic or workflow-heavy
## Option 3: Start with model-call guardrails
Model-call guardrails are usually the best first rollout when the core concern is:
- keeping integration simple
- proving value quickly
- getting Cycles into production with minimal architecture change
- controlling the most obvious source of cost first
A model-call guardrail means:
- reserve before a model invocation
- execute the call
- commit actual usage afterward (unused remainder is released automatically)
- or release explicitly if the call is canceled
This is often the easiest place to introduce Cycles because model calls are already clear cost events.
### Why model-call guardrails are attractive
They have the lowest integration friction.
Instead of redesigning all workflow and tool policy upfront, the team can start by guarding the most expensive or frequent model calls.
This works especially well in Spring AI or JVM systems where the first integration surface is already clear.
### What model-call guardrails solve well
They are strong at:
- putting hard checks around model spend
- proving reserve → commit in a small surface area
- enabling shadow mode quickly
- creating an adoption path without deep workflow modeling
- reducing initial rollout complexity
### What model-call guardrails do not solve alone
They do not fully protect against:
- tool costs
- side-effecting actions
- whole-run overages across many steps
- tenant-level aggregate usage
- expensive non-model paths
So they are often the easiest first rollout, but not the most complete.
### Choose model-call guardrails first if:
- you want the lowest-friction first integration
- model usage is your dominant cost center
- you are integrating through Spring AI or a similar runtime
- you want to prove Cycles value before adding broader policy
- your current system is not yet deeply autonomous but is heading there
## How to decide among the three
A simple decision rule works well.
### Choose tenant budgets when the first problem is:
**Who is allowed to consume how much overall?**
### Choose run budgets when the first problem is:
**How do we stop one execution from going too far?**
### Choose model-call guardrails when the first problem is:
**What is the smallest useful place we can start enforcing?**
That framing usually makes the right first rollout obvious.
## A quick decision matrix
### Start with tenant budgets if:
- you are multi-tenant
- customers need isolated limits
- commercial plan boundaries matter now
- leadership wants predictable account-level controls
- the platform needs a top-level spend envelope
### Start with run budgets if:
- you have agent loops or autonomous workflows
- runaway execution is the main incident class
- retries and recursive tools are common
- local execution safety matters most
- one bad run can cause meaningful damage
### Start with model-call guardrails if:
- you want a simple first integration
- you already know where model calls happen
- LLM spend is the most obvious first budget surface
- you need to demonstrate value fast
- you want to start in shadow mode with minimal disruption
## What I would recommend by system type
### Multi-tenant AI SaaS platform
Start with **tenant budgets**, then add **run budgets**.
Why:
You need commercial isolation first, but you will likely need local execution safety soon after.
### Agentic workflow platform
Start with **run budgets**, then add **tenant budgets**.
Why:
Your first operational risk is usually one execution doing too much.
### Spring AI app with growing LLM spend
Start with **model-call guardrails**, then add **run budgets**.
Why:
The fastest first integration is around model calls, but bounded execution will matter as autonomy increases.
### Internal enterprise assistant
Start with **run budgets** or **model-call guardrails**, depending on architecture.
Why:
Tenant isolation may matter less initially than preventing loops and keeping model cost bounded.
### AI gateway or proxy product
Start with **tenant budgets** plus **model-call guardrails**.
Why:
The gateway naturally sees account and request boundaries first.
## A strong default rollout path
If you want a generally strong sequence, this is a good one:
### Phase 1: Model-call guardrails
Get Cycles into the execution path around model calls.
### Phase 2: Run budgets
Add bounded execution for complete runs or workflow instances.
### Phase 3: Tenant budgets
Add top-level account or customer boundaries.
### Phase 4: Workflow-specific policies
Differentiate expensive or high-value processes.
This sequence works well because it moves from easiest integration to strongest operational safety to strongest commercial control.
## Start narrow, not broad
Another common mistake is to ask:
::: info
What is the perfect first policy model?
:::
A better question is:
::: info
What is the narrowest rollout that prevents the incident we care about most?
:::
That mindset leads to faster adoption.
The first Cycles rollout should aim for:
- one clear scope
- one clear integration point
- one clear incident class
- one clear operational win
That is enough.
## Shadow mode can reduce rollout risk
No matter which first rollout you choose, shadow mode is often the safest way to start.
That means:
- send reservation requests with `dry_run: true`
- log would-allow and would-deny responses in the application
- compare estimates with actuals from application telemetry because dry runs cannot be committed
- tune thresholds before hard enforcement
This is especially useful if you are unsure whether tenant ceilings, run envelopes, or model-level estimates are well calibrated yet.
## Common mistakes
### Mistake 1: Starting with tenant budgets when the real pain is runaway runs
This gives account-level control but may leave the main operational incident unchanged.
### Mistake 2: Starting with run budgets when the real business pressure is customer over-consumption
This improves execution safety, but may not solve the commercial problem leadership actually cares about.
### Mistake 3: Starting with every action type at once
This makes policy hard to reason about and adoption harder than necessary.
### Mistake 4: Treating model-call guardrails as the final state
They are often the best starting point, but many systems eventually need broader workflow and scope-level policy.
### Mistake 5: Choosing based on architecture purity instead of real incidents
The right first rollout is the one tied to actual pain.
## A practical recommendation
If you are unsure, use this order of preference:
- choose **run budgets** first if you already have autonomous loops or multi-step workflows
- choose **tenant budgets** first if you are multi-tenant and commercial isolation is the main concern
- choose **model-call guardrails** first if you need the lowest-friction path to proving value
That is a strong default rule.
## Summary
The best first Cycles rollout depends on what you need to control first:
- **tenant budgets** for account-level boundaries and usage isolation
- **run budgets** for bounded execution and runaway loop prevention
- **model-call guardrails** for the simplest first integration around LLM cost
Do not start by modeling everything.
Start with the scope that most directly addresses your current incident or pressure point.
That is how Cycles becomes adoptable, useful, and operationally credible.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
Cycles provides runtime budget authority for autonomous agents. At the boundaries you instrument, it reserves spend or caller-assigned action exposure **before** an LLM call, tool, or side effect. Your application still classifies and authorizes the action and must route every protected path through the boundary. This section takes you from zero to a working integration.
## New to Cycles?
Start with **[What is Cycles?](/quickstart/what-is-cycles)** for a short orientation, then run through the **[end-to-end tutorial](/quickstart/end-to-end-tutorial)** to see the reserve-commit lifecycle in practice.
## Add Cycles to an existing application
| Stack | Guide |
|---|---|
| Python | [Python client](/quickstart/getting-started-with-the-python-client) |
| TypeScript | [TypeScript client](/quickstart/getting-started-with-the-typescript-client) |
| Rust | [Rust client](/quickstart/getting-started-with-the-rust-client) |
| Java / Spring Boot | [Spring Boot starter](/quickstart/getting-started-with-the-cycles-spring-boot-starter) |
| MCP host (Claude, Cursor, Windsurf) | [MCP server quickstart](/quickstart/getting-started-with-the-mcp-server) |
### Use an AI coding assistant to integrate
[Add Cycles with Claude or Codex](/how-to/add-cycles-with-claude-or-codex) — Claude Code, Cursor, GitHub Copilot, etc. can wire Cycles into an existing app from natural-language instructions.
## Deploy self-hosted Cycles
Cycles is Apache 2.0 and self-hosted — there is no managed cloud. Run it on your own infrastructure.
- [Architecture overview](/quickstart/architecture-overview-how-cycles-fits-together) — how the runtime server, admin API, dashboard, and events service fit together.
- [Deploy the full stack](/quickstart/deploying-the-full-cycles-stack) — server + admin + dashboard + events.
- [Self-host the runtime server](/quickstart/self-hosting-the-cycles-server) — the protocol-conforming server alone.
- [Deploy the events service](/quickstart/deploying-the-events-service) — webhook delivery and signed audit events.
- [Deploy the admin dashboard](/quickstart/deploying-the-cycles-dashboard) — UI for tenants, budgets, and audit.
## Connect AI tools via MCP
[Claude Desktop](/quickstart/mcp-claude-desktop) · [Claude Code](/quickstart/mcp-claude-code) · [Cursor](/quickstart/mcp-cursor) · [Windsurf](/quickstart/mcp-windsurf) · [MCP server over HTTP](/how-to/running-the-mcp-server-over-http)
## Plan your rollout
- [Add hard budget limits to Spring AI](/quickstart/how-to-add-hard-budget-limits-to-spring-ai-with-cycles) — the reservation pattern walked through end-to-end.
- [Choose a first rollout](/quickstart/how-to-choose-a-first-cycles-rollout-tenant-budgets-run-budgets-or-model-call-guardrails) — tenant budgets, run budgets, or model-call guardrails as a starting point.
## Next
- [**How-To Guides**](/how-to/) — recipes for integrations, budget patterns, operations, and troubleshooting.
- [**Cycles Protocol**](/protocol/) — the open specification for runtime budget authority.
- [**Why Cycles**](/why-cycles) — the case for runtime authority over autonomous systems.
# Add Cycles to Claude Code
This page is the exact setup for [Claude Code](https://claude.com/product/claude-code). For the protocol overview and reserve-commit lifecycle, see the [umbrella MCP quickstart](/quickstart/getting-started-with-the-mcp-server).
::: warning MCP availability is not enforcement
Registering this MCP server gives Claude Code access to Cycles tools — `cycles_reserve`, `cycles_commit`, `cycles_release`, and balance queries. **MCP is useful for local assistant workflows and discovery. It is not, by itself, a hard runtime control unless the host or tool harness is required to call Cycles before executing the real action.** For dispatch-path enforcement in Claude Code, [Cycles Budget Guard for Claude Code](/how-to/enforcing-budgets-in-claude-code-with-budget-guard) gates non-exempt tools with `PreToolUse` hooks. For other hosts, place the Cycles check in the execution path — SDK wrapper, gateway, or framework adapter. See [Add Cycles with Claude, Codex, Cursor, or Windsurf](/how-to/add-cycles-with-claude-or-codex) for the application-side recipe.
:::
## Prerequisites
- **Claude Code installed** ([install guide](https://code.claude.com/docs))
- **Node.js 20+ with `npx` available** — Claude Code launches the MCP server through `npx`.
- **A Cycles API key** (`cyc_live_...`) — see [API key setup](/quickstart/getting-started-with-the-mcp-server#prerequisites). Skip this for mock mode.
- **Cycles server running** locally or remote. Skip for mock mode.
## Setup
The fastest path is the CLI, passing env vars at registration so they ride with the server config:
```bash
claude mcp add \
--transport stdio \
--env CYCLES_API_KEY=cyc_live_... \
--env CYCLES_BASE_URL=http://localhost:7878 \
cycles \
-- npx -y @runcycles/mcp-server
```
`claude mcp add` defaults to `local` scope, which records the server in your user-level Claude Code config scoped to the current project's directory. To share with teammates, add `--scope project` (or `-s project`) — it writes a committable `.mcp.json` file at the project root. For a global server available in every project, use `--scope user`.
## Project-scoped config (alternative)
If you'd rather hand-edit a committable `.mcp.json` in your project root, use `${VAR}` expansion so secrets stay out of git:
```json
{
"mcpServers": {
"cycles": {
"command": "npx",
"args": ["-y", "@runcycles/mcp-server"],
"env": {
"CYCLES_API_KEY": "${CYCLES_API_KEY}",
"CYCLES_BASE_URL": "${CYCLES_BASE_URL:-http://localhost:7878}"
}
}
}
}
```
Commit `.mcp.json`, but **do not commit real secrets**. Each developer sets `CYCLES_API_KEY` in their own shell or secret manager. The first time someone opens the project, Claude Code prompts to approve the new MCP server.
## Try mock mode (no API key required)
```bash
claude mcp add --transport stdio --env CYCLES_MOCK=true cycles -- npx -y @runcycles/mcp-server
```
Returns realistic synthetic responses with no Cycles backend running. Generated IDs and timestamps vary between calls; mock mode performs no live enforcement.
## Verify
In Claude Code, ask:
> Check the budget balance for tenant acme-corp
Claude Code should invoke `cycles_check_balance` (you'll see a tool-call indicator) and return the balances. List registered MCP servers any time with:
```bash
claude mcp list
```
## Common gotchas
- **Env vars not captured automatically.** `claude mcp add` records the command but does not persist your current shell env into the config (a stdio server still inherits the environment of the shell that launched `claude`; `--env` makes the values deterministic). Either pass `--env KEY=VALUE` flags at registration (recommended) or use a project `.mcp.json` with `${VAR}` expansion.
- **CLI option order matters.** `--transport`, `--env`, and `--scope` must come before the server name (`cycles`). Arguments after `--` are passed to `@runcycles/mcp-server`.
- **Native Windows needs a wrapper.** If Claude Code runs on native Windows rather than WSL, use `-- cmd /c npx -y @runcycles/mcp-server` after the server name so Windows can launch `npx`.
- **Three scopes, not two.** `local` is the default and applies only to the current project, stored in your user config. `project` writes a shared `.mcp.json` in the project root. `user` applies in every project. If the same server name appears in multiple scopes, Claude Code uses the highest-precedence definition: **local → project → user**.
- **First-time approval prompt.** Claude Code prompts before running a new MCP server. If you don't see the Cycles tools, check that you didn't miss the prompt.
- **`claude mcp add` updates Claude Code's view, not Claude Desktop's.** Each client has its own config — see [Claude Desktop setup](/quickstart/mcp-claude-desktop) if you want both.
## What Cycles adds
MCP gives Claude Code a standard way to call tools. Cycles adds runtime authority before those tools run: budget checks, risk limits, tenant scope, and reserve → commit / release accounting.
## Next steps
- [Reserve / commit lifecycle](/quickstart/getting-started-with-the-mcp-server#the-reserve-commit-lifecycle) — what the agent actually does with these tools
- [Claude Desktop setup](/quickstart/mcp-claude-desktop) — same protocol, different config
- [Cursor setup](/quickstart/mcp-cursor) · [Windsurf setup](/quickstart/mcp-windsurf)
- [HTTP transport](/how-to/running-the-mcp-server-over-http) — for shared / multi-user gateway deployments
- [Integrating Cycles with MCP](/how-to/integrating-cycles-with-mcp) — advanced patterns
# Add Cycles to Claude Desktop
This page is the exact setup for [Claude Desktop](https://claude.com/download). For the protocol overview and reserve-commit lifecycle, see the [umbrella MCP quickstart](/quickstart/getting-started-with-the-mcp-server).
::: warning MCP availability is not enforcement
Registering this MCP server gives Claude Desktop access to Cycles tools — `cycles_reserve`, `cycles_commit`, `cycles_release`, and balance queries. **MCP is useful for local assistant workflows and discovery. It is not, by itself, a hard runtime control unless the host or tool harness is required to call Cycles before executing the real action.** For production, place the Cycles check in the execution path — SDK wrapper, gateway, or framework adapter. See [Add Cycles with Claude, Codex, Cursor, or Windsurf](/how-to/add-cycles-with-claude-or-codex) for the application-side recipe.
:::
## Prerequisites
- **Claude Desktop installed** ([download](https://claude.com/download))
- **Node.js 20+** on PATH if you use the manual `npx` configuration below.
- **A Cycles API key** (`cyc_live_...`) — see [API key setup](/quickstart/getting-started-with-the-mcp-server#prerequisites). Skip this if you only want to try mock mode below.
- **Cycles server running** locally or remote. Skip this for mock mode.
## Setup
### Desktop extension (recommended)
1. Download `cycles-mcp-server-0.6.0.mcpb` from the [latest Cycles MCP Server release](https://github.com/runcycles/cycles-mcp-server/releases/latest).
2. In Claude Desktop, open **Settings → Extensions → Advanced settings → Install Extension…** and select the downloaded file.
3. Enter your Cycles server URL and API key in the extension configuration screen. To explore without a backend, enable **Mock mode** instead; mock mode is synthetic and performs no enforcement.
Claude Desktop installs the bundled server and makes the Cycles tools available without a hand-edited JSON file. Restart Claude Desktop if the tools do not appear immediately.
### Manual JSON configuration
Use this fallback when desktop extensions are disabled by policy or when you need to manage the launch command directly.
Open **Settings → Developer → Edit Config**. Or edit the file directly:
**macOS:**
```
~/Library/Application Support/Claude/claude_desktop_config.json
```
**Windows:**
```
%APPDATA%\Claude\claude_desktop_config.json
```
On macOS, paste the following, replacing `cyc_live_...` with your real API key:
```json
{
"mcpServers": {
"cycles": {
"command": "npx",
"args": ["-y", "@runcycles/mcp-server"],
"env": {
"CYCLES_API_KEY": "cyc_live_...",
"CYCLES_BASE_URL": "http://localhost:7878"
}
}
}
}
```
On Windows, launch the `npx.cmd` wrapper through `cmd /c`:
```json
{
"mcpServers": {
"cycles": {
"command": "cmd",
"args": ["/c", "npx", "-y", "@runcycles/mcp-server"],
"env": {
"CYCLES_API_KEY": "cyc_live_...",
"CYCLES_BASE_URL": "http://localhost:7878"
}
}
}
}
```
**Quit Claude Desktop completely** (cmd+Q on macOS — closing the window is not enough), then reopen. The Cycles tools should appear in the MCP indicator at the bottom of the chat.
> **Security note:** if you put `CYCLES_API_KEY` directly in this file, treat the config file as a secret. For shared machines, use a wrapper script or a local-only test key.
## Try mock mode (no API key required)
For the desktop extension, enable **Mock mode** in its configuration screen. For a manual JSON installation, drop `CYCLES_API_KEY` and `CYCLES_BASE_URL`, and set `CYCLES_MOCK` instead. The server returns realistic synthetic responses with no Cycles backend running. Generated IDs and timestamps vary between calls, and mock mode performs no live enforcement:
```json
{
"mcpServers": {
"cycles": {
"command": "npx",
"args": ["-y", "@runcycles/mcp-server"],
"env": { "CYCLES_MOCK": "true" }
}
}
}
```
On Windows, keep the `command: "cmd"` and `["/c", "npx", ...]` argument prefix from the real-mode example.
Useful for trying out the tools before standing up a stack.
## Verify
In Claude Desktop, ask:
> Check the budget balance for tenant acme-corp
Claude should call `cycles_check_balance` and return the balances. If you don't see a tools indicator or the call doesn't fire, see "Common gotchas" below.
## Common gotchas
- **Indicator missing after edit.** Claude Desktop only re-reads the config on a full quit/restart. Closing the window is not enough on macOS.
- **Manual `npx` launch fails on Windows.** Make sure Node 20+ is on PATH and `where npx` resolves, then verify the config uses `command: "cmd"` with `"/c", "npx"` at the start of `args`.
- **`CYCLES_BASE_URL` reachability.** If your Cycles server is in Docker, `localhost:7878` from Claude Desktop on macOS reaches the host's localhost — that works. From inside another container, use `host.docker.internal`.
- **API key starts with `cyc_test_` not `cyc_live_`.** Test keys work but only against test budgets; if you're getting `BUDGET_NOT_FOUND` errors, double-check the tenant has a budget allocated.
- **Where are the logs?** When the indicator stays empty or tools fail silently, Claude writes MCP logs to `~/Library/Logs/Claude/` on macOS and `%APPDATA%\Claude\logs\` on Windows. Tail the `mcp*.log` files while restarting the app.
## What Cycles adds
MCP gives Claude Desktop a standard way to call tools. The Cycles server adds budget checks, caller-assigned risk budgets, tenant scope, and reserve → commit/release accounting as tools. Those tools are cooperative in Claude Desktop; hard enforcement requires a host or application boundary that Claude Desktop cannot bypass.
## Next steps
- [Reserve / commit lifecycle](/quickstart/getting-started-with-the-mcp-server#the-reserve-commit-lifecycle) — what the agent actually does with these tools
- [Claude Code setup](/quickstart/mcp-claude-code) — same protocol, different config
- [HTTP transport](/how-to/running-the-mcp-server-over-http) — for shared / multi-user gateway deployments
- [Integrating Cycles with MCP](/how-to/integrating-cycles-with-mcp) — advanced patterns: preflight decisions, graceful degradation, fire-and-forget events
# Add Cycles to Cursor
This page is the exact setup for [Cursor](https://cursor.com). For the protocol overview and reserve-commit lifecycle, see the [umbrella MCP quickstart](/quickstart/getting-started-with-the-mcp-server).
::: warning MCP availability is not enforcement
Registering this MCP server gives Cursor access to Cycles tools — `cycles_reserve`, `cycles_commit`, `cycles_release`, and balance queries. **MCP is useful for local assistant workflows and discovery. It is not, by itself, a hard runtime control unless the host or tool harness is required to call Cycles before executing the real action.** For production, place the Cycles check in the execution path — SDK wrapper, gateway, or framework adapter. See [Add Cycles with Claude, Codex, Cursor, or Windsurf](/how-to/add-cycles-with-claude-or-codex) for the application-side recipe.
:::
## Prerequisites
- **Cursor installed** ([download](https://cursor.com))
- **Node.js 20+ with `npx` available** — Cursor launches the MCP server through `npx`.
- **A Cycles API key** (`cyc_live_...`) — see [API key setup](/quickstart/getting-started-with-the-mcp-server#prerequisites). Skip for mock mode.
- **Cycles server running** locally or remote. Skip for mock mode.
## Setup
Cursor reads MCP config from two locations:
- **Project-scoped:** `.cursor/mcp.json` in the project root (commit to share with teammates)
- **User-scoped:** `~/.cursor/mcp.json` (applies to every project)
Pick one. Most teams want project-scoped so the config travels with the repo. Create the file:
```json
{
"mcpServers": {
"cycles": {
"command": "npx",
"args": ["-y", "@runcycles/mcp-server"],
"env": {
"CYCLES_API_KEY": "${env:CYCLES_API_KEY}",
"CYCLES_BASE_URL": "${env:CYCLES_BASE_URL}"
}
}
}
}
```
Set `CYCLES_API_KEY` and `CYCLES_BASE_URL` in the environment where Cursor can read them. Open Cursor's settings panel → MCP, and you should see `cycles` listed. Toggle it on if it isn't already. Cursor may prompt to approve the new server the first time.
## Try mock mode (no API key required)
```json
{
"mcpServers": {
"cycles": {
"command": "npx",
"args": ["-y", "@runcycles/mcp-server"],
"env": { "CYCLES_MOCK": "true" }
}
}
}
```
Returns realistic synthetic responses with no Cycles backend running. Generated IDs and timestamps vary between calls; mock mode performs no live enforcement.
## Verify
In Cursor's tool-enabled Agent / Composer flow, ask:
> Check the budget balance for tenant acme-corp
Cursor should invoke `cycles_check_balance` (you'll see the tool-call expand in the chat) and return balances. The MCP indicator in Cursor's settings should show the server as connected.
## Common gotchas
- **MCP tools only fire in Cursor's agentic / tool-enabled mode**, not plain autocomplete or basic chat. The exact label varies across Cursor releases — check the mode toggle in the chat panel.
- **Project vs user scope.** If both `.cursor/mcp.json` and `~/.cursor/mcp.json` define `cycles`, project wins inside that project's workspace.
- **Use env interpolation for secrets.** Cursor expands `${env:NAME}` in MCP config fields including `env`, so project config can be shared without committing the API key. If Cursor was launched from a GUI and cannot see your shell variables, use a local `.env` via `envFile` or set the variables in your OS environment.
- **`.cursor/` should be `.gitignore`d if it contains secrets.** If you commit `.cursor/mcp.json` with the API key in plain text, it ends up in git history. Commit only env references or mock-mode config; keep any local `.env` file out of git.
- **If Cursor changes config locations**, use **Settings → MCP** to open or verify the active config file — the in-app path is the source of truth.
## What Cycles adds
MCP gives Cursor a standard way to call tools. Cycles adds runtime authority before those tools run: budget checks, risk limits, tenant scope, and reserve → commit / release accounting.
## Next steps
- [Reserve / commit lifecycle](/quickstart/getting-started-with-the-mcp-server#the-reserve-commit-lifecycle) — what the agent actually does with these tools
- [Windsurf setup](/quickstart/mcp-windsurf) — same protocol, different config
- [Claude Desktop](/quickstart/mcp-claude-desktop) · [Claude Code](/quickstart/mcp-claude-code)
- [HTTP transport](/how-to/running-the-mcp-server-over-http) — for shared / multi-user gateway deployments
- [Integrating Cycles with MCP](/how-to/integrating-cycles-with-mcp) — advanced patterns
# Add Cycles to Windsurf / Devin Desktop
[Windsurf has transitioned to Devin Desktop](https://devin.ai/desktop). The configuration paths below apply to legacy Windsurf builds; for current Devin Desktop releases, verify the active MCP configuration location in the app before applying them. For the protocol overview and reserve-commit lifecycle, see the [umbrella MCP quickstart](/quickstart/getting-started-with-the-mcp-server).
::: warning MCP availability is not enforcement
Registering this MCP server gives Windsurf access to Cycles tools — `cycles_reserve`, `cycles_commit`, `cycles_release`, and balance queries. **MCP is useful for local assistant workflows and discovery. It is not, by itself, a hard runtime control unless the host or tool harness is required to call Cycles before executing the real action.** For production, place the Cycles check in the execution path — SDK wrapper, gateway, or framework adapter. See [Add Cycles with Claude, Codex, Cursor, or Windsurf](/how-to/add-cycles-with-claude-or-codex) for the application-side recipe.
:::
## Prerequisites
- **Windsurf installed**, or a current [Devin Desktop](https://devin.ai/desktop) build with MCP support
- **Node.js 20+ with `npx` available** — Windsurf launches the MCP server through `npx`.
- **A Cycles API key** (`cyc_live_...`) — see [API key setup](/quickstart/getting-started-with-the-mcp-server#prerequisites). Skip for mock mode.
- **Cycles server running** locally or remote. Skip for mock mode.
## Setup
Edit Windsurf's MCP config file:
The fastest way to open the active config is from inside the app: **Windsurf Settings → Cascade → MCP Servers → View Raw Config**. Or open it directly:
**macOS / Linux:**
```
~/.codeium/windsurf/mcp_config.json
```
**Windows** (location varies by install — common paths):
```
%USERPROFILE%\.codeium\windsurf\mcp_config.json
%APPDATA%\Codeium\Windsurf\mcp_config.json
```
If neither path exists, use the in-app **View Raw Config** option above to find the active file.
Create the file if it doesn't exist:
```json
{
"mcpServers": {
"cycles": {
"command": "npx",
"args": ["-y", "@runcycles/mcp-server"],
"env": {
"CYCLES_API_KEY": "${env:CYCLES_API_KEY}",
"CYCLES_BASE_URL": "${env:CYCLES_BASE_URL}"
}
}
}
}
```
Set `CYCLES_API_KEY` and `CYCLES_BASE_URL` in the environment where Windsurf can read them. Open Windsurf's settings → Cascade → MCP servers, and `cycles` should appear in the list. Toggle on / refresh if needed.
## Try mock mode (no API key required)
```json
{
"mcpServers": {
"cycles": {
"command": "npx",
"args": ["-y", "@runcycles/mcp-server"],
"env": { "CYCLES_MOCK": "true" }
}
}
}
```
Returns realistic synthetic responses with no Cycles backend running. Generated IDs and timestamps vary between calls; mock mode performs no live enforcement.
## Verify
In Windsurf's Cascade chat, ask:
> Check the budget balance for tenant acme-corp
Cascade should invoke `cycles_check_balance` and return the balances. The tool call should be visible in the chat trail.
## Common gotchas
- **Config is typically user-scoped.** Per-project / per-workspace MCP overrides are rolling out across Windsurf release channels; check Windsurf's settings panel for "MCP servers" before assuming. If your build only supports user-scoped, use a wrapper script as the `command` that reads the right secret based on the working directory to vary keys per project.
- **Cascade mode required.** MCP tools are only available in Cascade (Windsurf's agent mode), not in inline completions or plain chat.
- **Use env interpolation for secrets.** Windsurf expands `${env:NAME}` in MCP config fields including `env`, `url`, `serverUrl`, and `headers`. If Windsurf was launched from a GUI and cannot see your shell variables, set them in your OS environment or use a wrapper script.
- **Tools list refreshes on Windsurf restart.** If you edit the config and the tools don't show, fully quit and reopen Windsurf (closing the window is not enough on macOS).
- **Cascade has a total MCP tool limit** (currently 100 tools across all enabled servers). Cycles exposes only 9 tools, but if you have many MCP servers enabled at once you may hit the cap — disable unused tools in the MCP settings panel.
## What Cycles adds
MCP gives Windsurf a standard way to call tools. Cycles adds runtime authority before those tools run: budget checks, risk limits, tenant scope, and reserve → commit / release accounting.
## Next steps
- [Reserve / commit lifecycle](/quickstart/getting-started-with-the-mcp-server#the-reserve-commit-lifecycle) — what the agent actually does with these tools
- [Cursor setup](/quickstart/mcp-cursor) — same protocol, different config
- [Claude Desktop](/quickstart/mcp-claude-desktop) · [Claude Code](/quickstart/mcp-claude-code)
- [HTTP transport](/how-to/running-the-mcp-server-over-http) — for shared / multi-user gateway deployments
- [Integrating Cycles with MCP](/how-to/integrating-cycles-with-mcp) — advanced patterns
# Self-Hosting the Cycles Server
The Cycles server is a Spring Boot application that enforces budget reservations backed by Redis. This guide covers how to run it locally, with Docker, and in production.
::: tip This guide covers the runtime Cycles Server (port 7878) only
The full Cycles stack includes three services: the **Cycles Server** (runtime enforcement, covered here), the **Admin Server** (port 7979, tenant/budget management), and the optional **Events Service** (outbound webhook delivery worker; app port 7980 and management port 9980 stay internal). See [Deploying the Full Cycles Stack](/quickstart/deploying-the-full-cycles-stack) for the end-to-end guide covering all components. For a web UI on top of the stack, see [Deploy the Admin Dashboard](/quickstart/deploying-the-cycles-dashboard).
:::
## Choose your path
- **Just the runtime server** — follow this guide.
- **Full local stack (server + admin + dashboard)** — see [Deploying the Full Cycles Stack](/quickstart/deploying-the-full-cycles-stack). Recommended for evaluation.
- **Evaluate Cycles for a multi-tenant agent SaaS** — start with the [evaluation guide](/how-to/evaluate-cycles-for-agent-saas) before deciding what to deploy.
- **Not sure where Cycles fits?** [Send us your agent/tool-call flow](/contact) and we'll map where `reserve` / `commit` should sit.
## Prerequisites
- **Docker** and **Docker Compose** (for the quick path — no Java needed), or
- **Java 21+** and **Maven 3.9+** (for building from source)
- **Redis 7+** (required for Lua script compatibility)
## Quick start with Docker Compose
### Using pre-built GHCR images (recommended)
The fastest way to get the Cycles server running. No Java or Maven required:
```bash
cd cycles-server
docker compose -f docker-compose.prod.yml up -d
```
This pulls `ghcr.io/runcycles/cycles-server:0.1.25.59` (the version pinned in `docker-compose.prod.yml`) and starts it with Redis. The prod compose file requires `REDIS_PASSWORD` and `ADMIN_API_KEY` to be set and fails fast if either is missing.
::: tip Pinning versions
`docker-compose.prod.yml` pins a specific version tag so deployments are reproducible. To upgrade, update the existing pin to the new tag and re-run `docker compose -f docker-compose.prod.yml up -d`. Check [GitHub releases](https://github.com/runcycles/cycles-server/releases) for the current stable version.
:::
### Building from source with Docker
The repository includes a multi-stage Dockerfile that builds the JAR inside Docker — no local Java or Maven needed:
```bash
cd cycles-server
docker compose up -d
```
This uses `docker-compose.yml` which builds from source via the multi-stage Dockerfile.
### Full stack (with Admin Server)
To run both the Cycles Server and Admin Server together:
```bash
cd cycles-server
docker compose -f docker-compose.full-stack.yml up -d # build from source
docker compose -f docker-compose.full-stack.prod.yml up -d # use GHCR images
```
The full-stack compose files expect `cycles-server-admin` and `cycles-server-events` to be cloned alongside as sibling directories.
The server is available at `http://localhost:7878`.
Verify it is running:
```bash
curl http://localhost:7878/actuator/health/readiness
```
Since cycles-server 0.1.25.45 the aggregate `/actuator/health`, `/actuator/prometheus`, and the API docs/Swagger UI require an `X-Admin-API-Key` header — only the liveness/readiness probes stay public.
## Running from source
Clone the repository and build:
```bash
git clone https://github.com/runcycles/cycles-server.git
cd cycles-server/cycles-protocol-service
mvn clean package -DskipTests
```
Start Redis (if not already running):
```bash
redis-server
```
Run the server:
```bash
java -jar cycles-protocol-service-api/target/cycles-protocol-service-api-*.jar
```
The server starts on port 7878 by default.
## Configuration
The server is configured via environment variables or `application.properties`.
### Environment variables
| Variable | Default | Description |
|---|---|---|
| `REDIS_HOST` | `localhost` | Redis server hostname |
| `REDIS_PORT` | `6379` | Redis server port |
| `REDIS_PASSWORD` | (empty) | Redis password (optional) |
| `ADMIN_API_KEY` | (empty) | Admin key for the protected operational endpoints and admin-on-behalf-of dual-auth paths. Required (`:?`) in the prod compose files |
| `DASHBOARD_CORS_ORIGIN` | (empty) | Comma-separated browser origin(s) allowed for the Cycles dashboard. Passed through in the stack compose files; set the same value on the admin server |
| `WEBHOOK_SECRET_ENCRYPTION_KEY` | (empty) | Encryption key for webhook secrets, shared with `cycles-server-admin` and `cycles-server-events` |
| `CYCLES_PUBLIC_RATE_LIMIT_ENABLED` | `true` | Per-IP 429 rate limiting on the public evidence/JWKS endpoints (since 0.1.25.46) |
| `CYCLES_PUBLIC_RATE_LIMIT_REQUESTS_PER_MINUTE` | `300` | Fixed-window per-IP request budget for the public endpoints |
| `server.port` | `7878` | HTTP server port |
| `cycles.expiry.interval-ms` | `5000` | Interval for the background reservation expiry sweep (ms) |
### Application properties (excerpt)
The most commonly tuned properties from `application.properties`. See the [Server Configuration Reference](/configuration/server-configuration-reference-for-cycles) for the full list, including evidence signing, audit retention, and event emitter settings:
```properties
# Server
server.port=7878
# Redis
redis.host=${REDIS_HOST:localhost}
redis.port=${REDIS_PORT:6379}
redis.password=${REDIS_PASSWORD:}
# Admin key for protected operational endpoints + dual-auth paths
admin.api-key=${ADMIN_API_KEY:}
# Webhook secret encryption (shared key with cycles-server-admin)
webhook.secret.encryption-key=${WEBHOOK_SECRET_ENCRYPTION_KEY:}
# Reservation expiry sweep interval
cycles.expiry.interval-ms=5000
# Public evidence/JWKS endpoint rate limit (since 0.1.25.46)
cycles.public-rate-limit.enabled=${CYCLES_PUBLIC_RATE_LIMIT_ENABLED:true}
cycles.public-rate-limit.requests-per-minute=${CYCLES_PUBLIC_RATE_LIMIT_REQUESTS_PER_MINUTE:300}
# Logging
logging.level.root=INFO
logging.level.io.runcycles.protocol=INFO
# OpenAPI / Swagger UI
springdoc.api-docs.path=/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.enabled=true
# Actuator (liveness/readiness probe groups enabled)
management.endpoints.web.exposure.include=health,info,prometheus
management.endpoint.health.show-details=when-authorized
management.endpoint.health.probes.enabled=true
```
## Redis connection
The server uses a JedisPool with a default maximum of 128 connections (32 max idle, 16 min idle, 2000 ms max wait). Redis 7+ is required because the Lua scripts use features not available in earlier versions.
### Redis with authentication
Set the `REDIS_PASSWORD` environment variable:
```bash
REDIS_PASSWORD=your-redis-password java -jar cycles-protocol-service-api-*.jar
```
### Redis connection pool
The default pool configuration (128 max total, 32 max idle, 16 min idle, 2000 ms max wait) is sufficient for most workloads. For high-throughput deployments, tune it with the `redis.pool.max-total`, `redis.pool.max-idle`, `redis.pool.min-idle`, and `redis.pool.max-wait-ms` properties — no code change required.
## Background expiry sweep
The server runs a background task every 5 seconds (configurable via `cycles.expiry.interval-ms`) that:
1. Scans the reservation TTL sorted set for expired entries
2. Marks expired reservations as `EXPIRED`
3. Releases their reserved budget back to the affected scopes
This ensures abandoned reservations (from crashed clients or network failures) do not permanently consume budget.
## Health checks
The server exposes Spring Boot Actuator health endpoints. The Kubernetes-style probes are public; readiness includes the Redis dependency:
```bash
# Liveness (process only)
curl http://localhost:7878/actuator/health/liveness
# Readiness (includes Redis)
curl http://localhost:7878/actuator/health/readiness
# Aggregate health — requires the admin key since 0.1.25.45
curl -H "X-Admin-API-Key: $ADMIN_API_KEY" http://localhost:7878/actuator/health
```
Since 0.1.25.45, the aggregate `/actuator/health`, `/actuator/prometheus`, `/actuator/info`, and the API docs/Swagger UI require the `X-Admin-API-Key` header. Only `/actuator/health/liveness` and `/actuator/health/readiness` remain public for orchestrators.
## Swagger UI
The server includes interactive API documentation via Swagger UI:
```
http://localhost:7878/swagger-ui.html
```
The raw OpenAPI spec is available at:
```
http://localhost:7878/api-docs
```
::: warning Admin key required; disabled in the prod compose
Since 0.1.25.45, Swagger UI and `/api-docs` require the `X-Admin-API-Key` header. The production compose files additionally disable them entirely via `SPRINGDOC_API_DOCS_ENABLED=false` and `SPRINGDOC_SWAGGER_UI_ENABLED=false`.
:::
## Production considerations
### Stateless server
The Cycles server is stateless — all state lives in Redis. You can run multiple server instances behind a load balancer without sticky sessions.
### Redis persistence
Enable Redis persistence (RDB or AOF) to survive Redis restarts without losing budget state. For production:
```
# redis.conf
appendonly yes
appendfsync everysec
```
### Redis memory
Budget data is compact. Each scope stores a few counters. Each active reservation stores its metadata. Typical memory usage is low unless you have millions of concurrent reservations.
### Security
- Always run the server behind HTTPS in production (use a reverse proxy like nginx or a cloud load balancer)
- Use strong, unique API keys per tenant
- Set `REDIS_PASSWORD` and restrict Redis network access
- Consider running Redis in a private subnet not accessible from the internet
### Scaling
For higher throughput:
- Add more Cycles server instances behind a load balancer
- Use Redis Cluster for horizontal scaling of budget state
- Tune the JedisPool connection count based on your concurrency needs
## Verifying your deployment
After starting the server, verify the full lifecycle works. You need an API key and a budget already configured via the [Cycles Admin Server](/quickstart/deploying-the-full-cycles-stack):
```bash
# Create a reservation (requires a valid API key and budget for the tenant scope)
curl -s -X POST http://localhost:7878/v1/reservations \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"idempotency_key": "test-001",
"subject": { "tenant": "acme-corp" },
"action": { "kind": "test", "name": "verify" },
"estimate": { "amount": 100, "unit": "USD_MICROCENTS" }
}'
```
If the server is configured correctly, you will receive a JSON response with a `reservation_id` and `"decision": "ALLOW"`.
If you get `BUDGET_EXCEEDED`, you need to create a budget via the admin server first. If you get `UNAUTHORIZED`, verify your API key was created correctly. See the [full stack deployment guide](/quickstart/deploying-the-full-cycles-stack) for the complete bootstrap sequence.
## Next steps
- [API Reference](/api/) — interactive endpoint documentation
- [Server Configuration Reference](/configuration/server-configuration-reference-for-cycles) — all configuration properties
- [Architecture Overview](/quickstart/architecture-overview-how-cycles-fits-together) — how the components fit together
# What is Cycles?
Cycles provides **runtime budget authority for autonomous agents**. At application-defined mandatory boundaries, it enforces spend and caller-assigned action-exposure limits **before instrumented execution**. The application remains responsible for tool authorization, argument validation, complete path coverage, and retaining non-persisting decisions and external outcomes.
::: tip Cycles provides three runtime-authority pillars
- **Spend** — reserve-commit budget enforcement before instrumented LLM calls and tool actions
- **Risky actions** — callers can budget assigned `RISK_POINTS`; applications must apply preflight decisions and any configured caps
- **Audit** — reservations, commits, releases, and direct-usage events create lifecycle records; non-persisting preflight decisions need application logging
:::
## Choose your path
- **Cap agent tool calls in JS/TS** — start with the [TypeScript client](/quickstart/getting-started-with-the-typescript-client) or the [OpenClaw budget guard](/how-to/integrating-cycles-with-openclaw).
- **Run Cycles locally to evaluate** — bring up the full stack: [runtime server, admin server, dashboard](/quickstart/deploying-the-full-cycles-stack).
- **Evaluate Cycles for a multi-tenant agent SaaS** — start with the [evaluation guide](/how-to/evaluate-cycles-for-agent-saas) for fit/no-fit framing and a 15-minute test.
- **Not sure where Cycles fits?** [Send us your agent/tool-call flow](/contact) and we'll map where `reserve` / `commit` should sit.
```python
@cycles(estimate=5000, action_kind="llm.completion", action_name="openai:gpt-4o")
def ask(prompt: str) -> str:
return openai.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
).choices[0].message.content
# Budget is reserved before the decorated action runs. On rejection, the decorator does not call it.
```
## The problem
Autonomous systems fail differently than traditional software. A runaway agent does not just burn dollars — **it creates unbounded exposure**.
That exposure can be financial: thousands of dollars in LLM calls accumulated before anyone notices. But it can just as easily be operational: records deleted, files overwritten, emails sent, orders placed, deployments triggered. In these cases, the damage is not measured primarily in cost, but in **consequence**.
Rate limiters control velocity — requests per second. They do not control total exposure: the cumulative cost, risk, or irreversible side effects a system is allowed to create before execution is halted. Nor do they constrain what each individual action is permitted to do.
> By the time an alert fires, the system has already acted. **Observation is useful for visibility. It is not enforcement.**
## See it in action
The [Demos](/demos/) page has self-contained scenarios you can run in 60 seconds — no LLM API key required:
- **Runaway Agent Demo** — same agent, same bug, two outcomes: without Cycles the agent burns ~$10 before being force-killed. With Cycles it stops cleanly at $1.00.
- **Action Authority Demo** — a support agent handles a billing dispute in four steps. Cycles allows internal actions but blocks the customer email before it executes.
## How Cycles solves it
Cycles enforces a budget decision before the instrumented LLM calls, tool invocations, and API requests that your application routes through it. Each protected path follows the **[reserve-commit lifecycle](/glossary#reservation)**:
> Cycles enforces where you instrument it. Uninstrumented code paths are unaffected.
```
1. Reserve → Lock estimated amount before the action runs
2. Execute → Call the LLM / tool / API
3. Commit → Record actual usage; unused budget is released automatically
```
If an applicable budget is exhausted, a live reservation is **rejected before the protected action executes**.
::: code-group
```python [Python]
from runcycles import CyclesClient, CyclesConfig, cycles, set_default_client
client = CyclesClient(CyclesConfig.from_env())
set_default_client(client)
@cycles(estimate=5000, action_kind="llm.completion", action_name="openai:gpt-4o") # [!code focus]
def ask(prompt: str) -> str:
return openai.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
).choices[0].message.content
# Cycles are reserved before the action, committed after, released on failure.
result = ask("Summarize this document")
```
```typescript [TypeScript]
import { CyclesClient, CyclesConfig, withCycles, setDefaultClient } from "runcycles";
const client = new CyclesClient(CyclesConfig.fromEnv());
setDefaultClient(client);
const ask = withCycles( // [!code focus]
{ estimate: 5000, actionKind: "llm.completion", actionName: "openai:gpt-4o" }, // [!code focus]
async (prompt: string) => {
const res = await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: prompt }],
});
return res.choices[0].message.content;
},
);
const result = await ask("Summarize this document");
```
:::
## Key guarantees
| Guarantee | What it means |
|---|---|
| **Atomic reservation** | Budget is locked across all affected scopes in one operation — no partial locks |
| **Concurrency-safe** | Multiple agents sharing a budget cannot oversubscribe |
| **Idempotent** | Retries are safe; the same action cannot settle twice |
| **Pre-enforcement** | An insufficient live reservation is rejected *before* the instrumented expensive action, not after |
## Multi-level scoping
Budgets are applied hierarchically. A single reservation can enforce limits at every level simultaneously:
```
tenant → workspace → app → workflow → agent → toolset
```
For example, a reservation with `tenant=acme, workspace=prod, app=chatbot` checks budget at:
- `tenant:acme`
- `tenant:acme/workspace:prod`
- `tenant:acme/workspace:prod/app:chatbot`
Any configured ledger among those three scopes must have sufficient budget for the reservation to succeed. A derived scope with no ledger is skipped; at least one applicable ledger in the requested unit must exist.
## Architecture
Your application talks to the **Cycles Server** for runtime budget checks. The **Admin Server** manages tenants, API keys, and budget ledgers. The **Events Service** (optional) delivers webhook notifications asynchronously — see [Deploying the Events Service](/quickstart/deploying-the-events-service).
## Who uses Cycles
- **Platform teams** building multi-tenant agent runtimes
- **Framework authors** integrating budget enforcement into SDKs
- **Enterprise operators** needing structured cost-accountability records
- **Teams building agents** that call paid APIs autonomously
## Get started
::: tip New to Cycles? Start here.
The **[End-to-End Tutorial](/quickstart/end-to-end-tutorial)** takes you from zero to a working budget-guarded app in ~10 minutes — deploy the stack, create a tenant, mint an API key, and run your first reservation. Do this first, then pick a language client below.
:::
### Already have a running server? Pick your client
| Stack | Guide | Time |
|-------|-------|------|
| **Python** | [Python Quickstart](/quickstart/getting-started-with-the-python-client) | ~5 min |
| **TypeScript / Node.js** | [TypeScript Quickstart](/quickstart/getting-started-with-the-typescript-client) | ~5 min |
| **Spring Boot / Java** | [Spring Boot Quickstart](/quickstart/getting-started-with-the-cycles-spring-boot-starter) | ~5 min |
| **Rust** | [Rust Quickstart](/quickstart/getting-started-with-the-rust-client) | ~5 min |
| **Claude / Cursor / Windsurf** | [MCP Server Quickstart](/quickstart/getting-started-with-the-mcp-server) | ~3 min |
### Need to deploy the server?
| Scenario | Guide | Time |
|---|---|---|
| **Single-server local** | [Self-hosting the Cycles Server](/quickstart/self-hosting-the-cycles-server) | ~5 min |
| **Full stack (runtime + admin + events)** | [Deploy the Full Stack](/quickstart/deploying-the-full-cycles-stack) | ~10 min |
| **Webhooks/events only** | [Deploy the Events Service](/quickstart/deploying-the-events-service) | ~5 min |
| **Admin dashboard (web UI)** | [Deploy the Admin Dashboard](/quickstart/deploying-the-cycles-dashboard) | ~10 min |
## Next steps
- **[Ballpark this for your workload](/calculators/claude-vs-gpt-cost-standalone)** — the cost calculator takes ~30 seconds and produces a shareable URL with your numbers
- [Choose a First Rollout](/quickstart/how-to-choose-a-first-cycles-rollout-tenant-budgets-run-budgets-or-model-call-guardrails) — decide your adoption strategy
- [Architecture Overview](/quickstart/architecture-overview-how-cycles-fits-together) — how the runtime, admin, and events components fit together
- [How Cycles Compares](/concepts/how-cycles-compares-to-rate-limiters-observability-provider-caps-in-app-counters-and-job-schedulers) — vs. rate limiters, observability, provider caps
## Read the foundations
For the structural arguments behind runtime authority and the cost / action / audit pillars:
- [Beyond Budget: How Cycles Controls Agent Actions, Not Just Spend](/blog/beyond-budget-how-cycles-controls-agent-actions) — why budget is one axis of three; risk and audit need their own enforcement.
- [The AI Agent Audit Trail You're Already Building](/blog/runtime-authority-byproducts-audit-trail-and-attribution-by-default) — how runtime-authority decisions become a structured audit ledger by default.
- [Runtime Authority vs Guardrails vs Observability](/blog/runtime-authority-vs-guardrails-vs-observability) — why pre-execution decisions are a different job from post-hoc tracing.
- [Agents Are Cross-Cutting. Your Controls Aren't.](/blog/agents-are-cross-cutting-your-controls-arent) — the layer argument: governance must span providers, tools, tenants, and workers.
- [Why Local-First Agent Runtimes Need Runtime Authority](/blog/every-local-first-agent-runtime-needs-budget-authority) — for teams running OpenClaw, Cline, Aider, Continue.
- [Python AI Agent Control: Cost, Risk, and Audit by Layer](/blog/python-ai-agent-control-cost-risk-audit-layers) — Python-specific six-layer view across the three pillars.
---
# Concepts
# Action Authority: Controlling What Agents Do
Budget authority controls how much an agent spends. Action authority controls what it does.
Both are dimensions of [runtime authority](/blog/what-is-runtime-authority-for-ai-agents) — the pre-execution control layer that decides whether an agent's next action should proceed. Budget authority caps financial exposure. Action authority caps operational exposure: emails sent, deploys triggered, records modified, files deleted.
## Why cost budgets are not enough
A support agent that sends 200 customer emails costs $1.40 in model tokens. A per-run budget of $100, $50, even $5 would not have stopped a single email. The risk was not monetary — it was reputational, operational, and commercial.
Dollar budgets are the wrong unit for action authority. The problem is not "the agent spent too much." The problem is "the agent did something it should not have done."
## RISK_POINTS — budgeting what money cannot measure
Cycles supports a **RISK_POINTS** unit for caller-assigned action exposure. Instead of denominating budgets in dollars or tokens, the application assigns point values to action classes based on blast radius:
| Action | Risk points | Rationale |
|--------|------------|-----------|
| Read CRM record | 0 | No side effects |
| Add internal note | 1 | Low blast radius, reversible |
| Send customer email | 50 | High blast radius, irreversible |
| Trigger deployment | 100 | Production impact |
A workflow can get a fixed risk-point budget. Every consequential action routed through the mandatory reservation boundary deducts from it. When that budget is exhausted, the boundary rejects another metered action. Application authorization still decides which tools and arguments are permitted.
## Toolset-scoped budgets
Caller-assigned exposure budgets can use **toolset-scoped budgets** — separate budgets for different categories of tools within the same agent run:
- **Internal tools** (CRM reads, note-taking) get a generous risk-point budget
- **External tools** (customer email, deploy) get a restrictive one
The agent can exhaust its email budget while an independent internal-tool budget remains available. The host decides whether to continue with read-only work. A live reservation succeeds with `ALLOW` or configured `ALLOW_WITH_CAPS`, or returns an error when budget is unavailable.
## Graceful degradation, not hard stops
Action authority does not require killing the agent. An application can select progressively stricter configured policies:
- **Normal phase**: Full tool access after application authorization
- **Restricted phase**: High-blast-radius actions disabled
- **Read-only phase**: Search and summarize only
- **Insufficient risk budget**: No further metered action in that scope
The current server does not switch these phases or tighten caps automatically as risk points are consumed. The application selects the policy or scope and enforces the returned tool-list caps.
This is the "disable" degradation strategy applied to action authority rather than cost control. See [Degradation Paths](/how-to/how-to-think-about-degradation-paths-in-cycles-deny-downgrade-disable-or-defer).
## Next steps
- [Glossary: Action Authority](/glossary#action-authority) — formal definition
- [AI Agent Action Control: Hard Limits on Side Effects](/blog/ai-agent-action-control-hard-limits-side-effects) — deep dive on the problem and solution
- [Runaway Agent Demo](/demos/) — a budget-bound loop demonstrating the same reservation boundary; it is not an application-permission demo
- [Exposure](/concepts/exposure-why-rate-limits-leave-agents-unbounded) — the broader concept of unbounded agent risk
# Coding Agents Need Runtime Authority
Coding agents are impressive.
They can search a codebase, scaffold features, write tests, fix bugs, and compress work that used to take hours into minutes. But as autonomous execution gets faster and cheaper, the need for runtime control does not go away.
It becomes more important.
> **Model a coding agent's blast radius:** [Blast Radius Risk Calculator →](/calculators/ai-agent-blast-radius-standalone) — DROP TABLE, schema migration, deploy, and read-only rows are pre-loaded with illustrative severity factors. Set the containment slider to an assumption supported by your actual authorization and budget controls.
Coding agents and runtime authority solve different problems at different layers. A coding agent is designed to complete work. Runtime authority is designed to decide whether autonomous work is still allowed to continue, under what limits, and with what reconciliation afterward.
This article covers the **runtime-layer** problem: reservations, enforcement, retries, concurrency, and bounded execution inside a single agent run. The business-layer problem — whether the work was worth funding in the first place — is covered separately in [Why Coding Agents Do Not Replace Cycles](/concepts/why-coding-agents-do-not-replace-cycles).
## What coding agents do well
Modern coding agents excel at:
- searching and understanding large codebases
- scaffolding features from high-level descriptions
- writing and updating tests
- fixing bugs across multiple files
- refactoring code to match patterns
- generating boilerplate and configuration
These capabilities compress developer cycles. A task that required reading dozens of files, understanding dependencies, and writing careful patches can happen in a single agent run.
That speed is the point.
It is also the source of a new control problem.
## What coding agents do not control
A coding agent is optimized to finish work. It is not designed to answer:
- **How much has this run already spent?**
- **Is this tenant allowed to consume more?**
- **Should this workflow downgrade to a cheaper model?**
- **Has cumulative retry cost exceeded the budget for this task?**
- **Should execution stop because a parent scope is exhausted?**
These are not questions about code quality or correctness. They are questions about whether autonomous execution is still authorized to continue.
Most coding agents have no built-in mechanism to answer them.
## The gap between execution and authority
The distinction matters because coding agents create real cost as they run:
- **LLM inference** — every model call costs tokens
- **Tool invocations** — code search, file reads, web lookups, and API calls accumulate
- **Retries** — failed steps retry, sometimes silently, multiplying spend
- **Fan-out** — a single high-level task can expand into dozens of subtasks
- **Long-running loops** — agents that iterate on test failures or linting errors can run indefinitely
Without runtime authority, the only control is to wait until the run finishes — or until someone notices the bill.
That is observability, not enforcement.
## Why provider caps and rate limits are not enough
Provider-level spending caps and rate limits are useful safety nets, but they solve a different problem.
**Rate limits** bound how fast a system can act. They do not bound how much total exposure a system creates over time.
**Provider controls** use vendor-defined organization, project, workspace, credit, or quota scopes. Those can be valuable hard or soft boundaries, but shared provider identities do not automatically express "this tenant may spend $50 on this run" or "this agent may use 100,000 tokens for this task."
**In-app counters** are fragile under concurrency. Two agents checking the same counter simultaneously can both proceed, creating double-spend that is only visible after the fact.
Coding agents need controls that are:
- **scoped** — per tenant, per workspace, per workflow, per agent
- **pre-authorized** — checked before execution, not after
- **concurrency-safe** — correct under parallel agent execution
- **reconciled** — actual usage committed, unused budget released
## What runtime authority looks like
Runtime authority introduces a control loop around autonomous execution:
1. **Reserve** — before work begins, declare estimated cost and reserve budget against one or more scopes
2. **Execute** — proceed only if reservation succeeds
3. **Commit** — report actual usage after work completes (unused remainder is released automatically)
4. **Release** — explicitly release budget if work is canceled
This is the [reserve-commit model](/protocol/how-reserve-commit-works-in-cycles) that Cycles implements.
For coding agents, this means:
- a run can check whether budget is available before starting
- each model call or tool invocation can debit against a scoped budget
- retries can share one reservation only when its estimate covers the whole retry envelope; otherwise each retry needs another reservation against the same scoped ledger
- if the budget is exhausted, the agent receives a clear signal to stop or degrade
- operators see real-time budget consumption, not just post-hoc bills
## Scoped budgets for multi-tenant platforms
Teams running coding agents for multiple tenants or users face a harder version of this problem.
A single global cap does not help when:
- tenant A should be allowed $100/day but tenant B only $20/day
- a specific workspace within a tenant has its own limit
- one agent run should not consume more than 50% of a tenant's remaining budget
Cycles supports [hierarchical scoped budgets](/protocol/api-reference-for-the-cycles-protocol) — budgets defined at any level of a scope tree (tenant, workspace, app, workflow, agent, toolset). A single reservation checks all applicable scopes atomically.
This means a coding agent running inside a multi-tenant platform can be governed by organizational policy without any custom enforcement logic in the agent itself.
## Events for direct-debit accounting
Not all coding agent work fits the reservation pattern. Some actions have a known cost at execution time — a fixed-price API call, a per-file processing fee, a flat-rate tool invocation.
For these, Cycles supports [events](/protocol/how-events-work-in-cycles-direct-debit-without-reservation) — direct-debit operations that atomically deduct from scoped budgets without a prior reservation.
Events give coding agent platforms a way to account for every unit of work, whether or not it was estimated in advance.
## The practical takeaway
Coding agents are getting faster, cheaper, and more autonomous.
That makes runtime authority more important, not less.
Keep building capable agents. But do not assume that the agent itself is the right place to enforce spending limits, tenant isolation, or organizational policy.
Those are infrastructure concerns. They belong in a control layer that is:
- **independent** of the agent's execution logic
- **atomic** under concurrency
- **hierarchical** across organizational scopes
- **reconciled** between estimated and actual cost
That is the problem Cycles exists to solve.
## Next steps
To learn more:
- Read [Why Coding Agents Do Not Replace Cycles](/concepts/why-coding-agents-do-not-replace-cycles) for the business-layer companion to this piece
- Read [Why Rate Limits Are Not Enough](/concepts/why-rate-limits-are-not-enough-for-autonomous-systems) for the broader case for runtime authority
- Understand the [reserve-commit lifecycle](/protocol/how-reserve-commit-works-in-cycles)
- See [how events work](/protocol/how-events-work-in-cycles-direct-debit-without-reservation) for direct-debit accounting
- Explore the full [API Reference](/protocol/api-reference-for-the-cycles-protocol)
- Try the [MCP Server](/quickstart/getting-started-with-the-mcp-server) to give a coding agent direct budget tool access — zero code changes
- Get started with the [Python Client](/quickstart/getting-started-with-the-python-client) or [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- [AI Agent Budget Control: Enforce Hard Spend Limits](/blog/ai-agent-budget-control-enforce-hard-spend-limits) — how the reserve-commit pattern works at runtime
# Comparisons
Teams evaluating Cycles usually already have some controls in place. This page helps you find the right comparison for your situation.
## Quick read
| Tool | Best for | Where Cycles fits |
|---|---|---|
| LiteLLM | Provider routing, gateway reservations, agent/session limits, MCP cost tracking | Shared application ledgers and a caller-managed lifecycle across protected services |
| Helicone | Observability, caching, window cost limits | Bounds spend pre-execution instead of after the fact |
| OpenRouter | Single-API model access, per-key caps | Adds per-tenant + per-run hierarchical budgets |
| LangSmith | Tracing/evaluation; private-beta LLM Gateway spend policies | Adds reserve-commit budgets at an application boundary, including non-LLM work |
| Guardrails AI | Content validation (PII, toxicity) | Bounds spend and caller-assigned exposure, not output content |
| Rate limiter | Velocity control (req/sec) | Bounds total consumption, not just velocity |
| Provider controls | Vendor organization/project/workspace limits | Adds application scopes such as tenant and workflow; a workflow value can be keyed per run |
| DIY wrapper | Quick prototype budget logic | Production concurrency, retries, multi-tenant safety |
| **Cycles** | **Atomic scoped budget authority before protected execution** | **Complements routing, content, authorization, and observability controls** |
Need all of it in one layer? [Talk to a founder](mailto:founder@runcycles.io) about your stack, or [run the local demo](/demos/) to see enforcement in action.
## Full capability matrix
| Approach | Pre-execution? | Per-tenant? | Cost-aware? | Action control? | Degradation? | Reserve-commit? |
|---|:---:|:---:|:---:|:---:|:---:|:---:|
| LiteLLM | Yes (reservations for supported routes) | Multiple gateway scopes | Yes, including MCP tracking | Gateway model/tool policies | Gateway routing/fallbacks | Gateway-managed reservation and reconciliation |
| Helicone | Window rate limit | Per-user/property | Yes | No | No | No |
| OpenRouter | Yes (key cap) | Per-key | Yes | No | No | No |
| LangSmith | Gateway: yes | Workspace/API key/user | Gateway: yes | No downstream tool authorization | Gateway model fallbacks | No |
| Guardrails AI | No | No | No | No | No | No |
| Rate limiter | Velocity only | Partial | No | No | No | No |
| Provider controls | Vendor-dependent soft or hard boundary | Provider identity only | Yes for covered usage | No application tool policy | Application chooses fallback | No application reserve-commit |
| DIY wrapper | Partial | Partial | Partial | No | No | No |
| **Cycles** | **Yes, when required by host** | **Yes** | **Yes** | **Caller-assigned RISK_POINTS budget; host authorizes** | **Configured caps returned; host applies** | **Yes** |
LiteLLM documents [budget reservations enabled by default](https://docs.litellm.ai/docs/proxy/users#budget-reservation), [agent iteration/session limits](https://docs.litellm.ai/docs/a2a_iteration_budgets), and [MCP cost tracking](https://docs.litellm.ai/docs/mcp_cost) (checked September 4, 2026). Reservation coverage depends on the route; session and MCP accounting should not be assumed to have identical admission semantics. Cycles' distinction is its caller-facing lifecycle and shared application scopes for instrumented operations, including work outside the gateway. See the [detailed comparison](/concepts/cycles-vs-litellm) for qualifications and an evaluation workload.
## By alternative
### Infrastructure you already run
- **[Cycles vs Rate Limiting](/concepts/cycles-vs-rate-limiting)** — rate limiters control velocity, not total consumption. An agent can stay within its request-per-second limit and still burn through an entire budget.
- **[Cycles vs Provider Cost Controls](/concepts/cycles-vs-provider-spending-caps)** — provider budgets, credits, and quotas use vendor-defined scopes and semantics. Cycles adds caller-defined budgets for instrumented application scopes.
- **[Cycles vs Custom Token Counters](/concepts/cycles-vs-custom-token-counters)** — in-app counters work until concurrency, retries, and hierarchical scopes make them unreliable.
### LLM proxies and gateways
- **[Cycles vs LiteLLM](/concepts/cycles-vs-litellm)** — LiteLLM routes, reserves gateway budgets, and provides agent/session controls and MCP accounting. Cycles applies a caller-managed lifecycle across shared application ledgers for protected model calls, paid APIs, and other operations. The host authorizes the action.
- **[Cycles vs Helicone](/concepts/cycles-vs-helicone)** — Helicone provides observability, caching, and window-based cost limits. Cycles provides cumulative budgets for caller-submitted operations; the host separately authorizes application actions.
- **[Cycles vs OpenRouter](/concepts/cycles-vs-openrouter)** — OpenRouter provides unified model access with per-key spending caps and guardrails. Cycles adds hierarchical runtime budgets and caller-assigned RISK_POINTS. OpenRouter selects the model; Cycles evaluates the budget request; the host governs the action and any delegation policy.
### Observability and content safety
- **[Cycles vs LangSmith](/concepts/cycles-vs-langsmith)** — LangSmith traces application behavior, and its private-beta LLM Gateway can enforce provider spend policies. Cycles adds application-boundary reserve-commit budgets, including instrumented non-LLM work.
- **[Cycles vs Guardrails AI](/concepts/cycles-vs-guardrails-ai)** — Guardrails AI validates content (hallucination, toxicity, PII). Cycles governs budgets and meters caller-assigned exposure; application authorization governs tools and arguments. They solve different problems and complement each other.
- **[Cycles vs LLM Proxies and Observability Tools](/blog/cycles-vs-llm-proxies-and-observability-tools)** — broader comparison of how Cycles complements the proxy and observability ecosystem.
### Build vs use
- **[You Can Vibe Code a Budget Wrapper](/blog/vibe-coding-budget-wrapper-vs-budget-authority)** — the gap between a prototype wrapper and a production runtime authority with concurrency safety, idempotency, and multi-tenant isolation.
## Full comparison
For a deep dive across all five alternative categories with capability matrices, see **[How Cycles Compares to Rate Limiters, Observability, Provider Caps, In-App Counters, and Job Schedulers](/concepts/how-cycles-compares-to-rate-limiters-observability-provider-caps-in-app-counters-and-job-schedulers)**.
## Next steps
- **[Runtime Authority vs Runtime Authorization](/concepts/runtime-authority-vs-runtime-authorization)** — how Cycles fits alongside identity-based agent governance (AWS Bedrock AgentCore Policy, Akeyless, agent IAM). Different layer, complementary not competitive.
- **[What Cycles Is Not](/concepts/what-cycles-is-not-billing-rate-limiting-orchestration-and-other-category-confusion)** — Cycles is not billing, not rate limiting, not orchestration. Clearing up category confusion.
- **[From Observability to Enforcement](/concepts/from-observability-to-enforcement-how-teams-evolve-from-dashboards-to-budget-authority)** — how teams evolve from dashboards to runtime authority.
- **[Why Rate Limits Are Not Enough](/concepts/why-rate-limits-are-not-enough-for-autonomous-systems)** — the deeper argument for why velocity controls fail for autonomous systems.
# CyclesEvidence: Verifiable Audit for Agent Decisions
Cycles enforces configured budgets for instrumented work in real time; the host retains action authorization. CyclesEvidence can make supported budget decisions **verifiable after the fact** through signed, content-addressed envelopes without querying the live ledger.
## The problem: a 200 OK is not proof
A normal Cycles response — `decision: ALLOW`, a reservation, a `409 BUDGET_EXCEEDED` — is enough for the **caller** who made the request and holds a live connection. It is useless to anyone else, later:
- An auditor reviewing what an agent was allowed to do three months ago.
- A counterparty (e.g. a payment or agent-passport system) that needs to confirm an action ran within an authorized budget, but doesn't trust the agent's self-report.
- A compliance process that must retain verifiable records for years, long after the reservation has expired from the ledger.
For all of them, "the server said ALLOW" is hearsay. CyclesEvidence replaces hearsay with a verifiable artifact.
## What it is
For each authorization lifecycle event, Cycles can emit a **CyclesEvidence envelope**: the request and response, wrapped in a JSON object that is
- **canonicalized** with [RFC 8785 JCS](https://www.rfc-editor.org/info/rfc8785/) (a deterministic byte form),
- **content-addressed** by `evidence_id` = the SHA-256 of those canonical bytes, computed with the `evidence_id` and `signature` fields both present and set to the empty string `""` — so the id *is* the integrity check, and
- **Ed25519-signed** by the Cycles server's key — so the origin is provable.
Five artifact types cover the whole lifecycle: `decide`, `reserve`, `commit`, `release`, and `error`.
Every relevant response carries an optional `cycles_evidence` reference:
```json
"cycles_evidence": {
"evidence_id": "8403bed43e13ef7d56a8ab402a9d29ee7dd2f405e24c0cacb51068341a5e7030",
"cycles_evidence_url": "https://cycles.example.com/v1/evidence/8403bed4…7030"
}
```
A consumer records the `evidence_id`, then fetches the signed envelope at `cycles_evidence_url` and verifies it — whenever it wants, offline. See [CyclesEvidence Envelopes](/protocol/cycles-evidence-envelopes-in-cycles) for the wire shape and verification recipe.
## Why it matters
**Tamper-evident.** Change one byte of a budget decision and the `evidence_id` no longer matches; forge the contents and the Ed25519 signature no longer verifies. You cannot quietly rewrite history. A valid signature proves the bytes came from a key, though — not that the key is the server's. That second question is *signer authority*, resolved separately against the server's published key set, which is built to keep verifying [across key rotations](/blog/rotating-keys-shouldnt-rewrite-history) (see [A Valid Signature Doesn't Tell You Who Signed It](/blog/a-valid-signature-doesnt-tell-you-who-signed-it)).
**Cross-system binding.** A receipt or agent-passport system can record a Cycles `evidence_id` and bind its own signed receipt to it — proving *"this agent's action ran within authorized budget scope X"* by composing two independent systems, without either trusting the other's live state. This is the integration that drove the feature (with [APS, the agent-passport-system](https://github.com/aeoess/agent-passport-system)).
**Denials are first-class evidence.** The highest-signal governance event is often *"the budget said no."* A non-dry `reserve` over budget surfaces as a `409 BUDGET_EXCEEDED` `error` envelope; committing an expired reservation as a `410`. Proving an action was **blocked** is frequently more valuable than proving one was allowed — and Cycles signs both.
**The full chain is reconstructable.** `decide → reserve → commit / release`, plus the error paths, each produce evidence, with the `reservation_id` carried into commit/release so the authorization → settlement chain can be rebuilt from the artifacts alone.
**Long-horizon retention.** Content-addressed, signed records are well-suited to long-horizon record-keeping — for example EU AI Act Article 12 retention — verifiable years later, independent of whether the original server is still running.
**Zero friction to produce.** The `evidence_id` is computed *synchronously* and returned in-band on the response; the expensive signing and storage happen asynchronously. Producing the proof costs the caller nothing extra — there is no separate "generate evidence" call. One consequence of the async split: a freshly returned `cycles_evidence_url` may transiently `404` while signing and storage complete — consumers should treat that as not-yet-available and retry briefly.
## What it is not
CyclesEvidence is the **receipt, not the gate.** Enforcement is the reserve-commit ledger itself; evidence does not change a real-time decision or make budgets "safer" in the moment. Its value comes after the decision: audit, dispute resolution, cross-system trust, and compliance.
It is also **off until configured.** A deployment must set a shared signing identity before any verifiable evidence is produced — see the operator [identity enablement runbook](https://github.com/runcycles/cycles-server-events/blob/main/docs/evidence-identity-enablement.md). Until then, Cycles enforces budgets exactly as before; it just doesn't emit signed receipts.
## In one line
CyclesEvidence makes a supported budget decision portable and verifiable. A complete action audit still needs correlated host authorization, tool arguments, execution results, and external outcomes.
## Related
- [CyclesEvidence Envelopes](/protocol/cycles-evidence-envelopes-in-cycles) — the envelope shape, `evidence_id` recipe, and how to verify.
- [Correlation and Tracing](/protocol/correlation-and-tracing-in-cycles) — `trace_id` ties evidence, events, and audit entries to the originating request.
- [Error Codes and Error Handling](/protocol/error-codes-and-error-handling-in-cycles) — the denial codes that surface as `error` evidence.
# Cycles vs Custom Token Counters: Build vs Buy for Agent Budget Control
Many teams that run AI agents in production begin with a token counter.
It starts the same way every time.
A developer adds a variable. After each LLM call, increment the counter by the number of tokens used. Before the next call, check if the counter has exceeded the limit.
```python
if total_tokens < max_tokens:
response = call_llm(prompt)
total_tokens += response.usage.total_tokens
else:
raise BudgetExceeded()
```
This works. For a while.
It works when you have one service, one process, one agent, and one developer who understands the counter. It stops working when any of those assumptions change.
This article explains where custom token counters break, why they break, and when to replace them with a dedicated runtime authority.
> **Run the numbers for your workload:** [Cost Calculator →](/calculators/claude-vs-gpt-cost-standalone) — token counters predict; the calculator separates the prediction (rates × volume) from the enforcement layer that bounds reality.
## The natural starting point
Building your own counter is the rational first move.
The requirements seem simple:
- Track how many tokens each run uses
- Stop the run when it exceeds a limit
- Maybe track per-tenant usage for billing
A database column, a Redis key, or even an in-memory variable can handle this. The implementation takes an afternoon. It ships quickly. It solves the immediate problem.
Teams that build counters are not doing anything wrong. They are responding to a real need with the simplest possible solution.
The problems emerge later, when the system grows.
## Where custom counters break
### Concurrency: read-then-increment is a race condition
The basic counter pattern is: read the current value, check if it is under the limit, proceed, then increment.
That is a textbook time-of-check-to-time-of-use (TOCTOU) race condition.
When two agent threads run concurrently:
1. Thread A reads the counter: 900 tokens used out of 1,000 limit.
2. Thread B reads the counter: 900 tokens used out of 1,000 limit.
3. Both threads see headroom. Both proceed.
4. Thread A's call uses 200 tokens. Thread B's call uses 200 tokens.
5. Actual total: 1,300 tokens. Budget exceeded by 30%.
This is not a theoretical concern; it is a common failure mode in counters that separate the check from the update.
Solving it correctly requires atomic compare-and-swap operations, database-level locking, or serialized access. Most ad hoc counters do not implement any of these. Even when they do, the implementation is often subtly wrong — it works under light load and breaks under production concurrency.
### Multi-process and multi-service: the counter is local
A counter stored in application memory only exists in one process.
When the system scales to multiple instances, each instance has its own counter. The budget is effectively multiplied by the number of instances. Three replicas of a service, each with a 1,000-token limit, actually allow 3,000 tokens.
Moving the counter to a shared store (Redis, PostgreSQL) solves the locality problem but introduces the concurrency problem. Now every read-check-increment must be atomic across a network boundary. Latency, retries, and connection failures add complexity.
Moving to a shared store also means every protected service that makes LLM calls needs to know about the counter, use the same key scheme, and handle failures consistently. That coordination cost grows with each new service.
### No reservation model: cannot hold budget for in-flight work
Custom counters typically track what has been used. They do not track what is currently in flight.
Consider an agent that has used 800 of its 1,000 token budget. It starts a new LLM call that is estimated to use 150 tokens. While that call is in flight, another thread checks the counter and sees 800. It also starts a call. Both calls complete. The total is 1,100.
The counter was accurate at the time of the check. It just did not account for work that was already happening.
A reservation model solves this. Before the call, the system reserves 150 tokens. The counter immediately reflects 950 (800 used + 150 reserved). The next thread sees 950 and knows the budget is nearly exhausted.
After the call completes, the reservation is committed at the actual cost. If the call used only 120 tokens, the remaining 30 are released.
Building a correct reservation model on top of a simple counter is a significant engineering effort. It requires atomic reservation, commit, release, and TTL-based expiry for reservations that never complete. Most teams do not build this.
### No hierarchical scopes
A counter tracks one number against one limit.
Production systems need limits at multiple levels:
- **Tenant level:** This customer may spend $500 per month.
- **Workspace level:** This workspace may spend $100 per day.
- **Workflow level:** This workflow type may spend $10 per execution.
- **Agent level:** This agent may spend $2 per session.
- **Toolset level:** This set of tools may spend $0.50 per call.
Enforcing all of these simultaneously means a single request must check budget at multiple levels before proceeding. Each level must be decremented atomically. If any level is insufficient, the action must be denied.
Building this with ad hoc counters means maintaining separate counters per level, with correct rollup logic, atomic multi-key operations, and consistent error handling. The complexity is substantial.
Cycles supports hierarchical scopes natively. A single reservation checks all applicable scopes in one atomic operation.
### No overage policies
A custom counter has two states: under budget and over budget. The response is binary: proceed or fail.
Production systems need more nuance.
When a tenant is approaching their budget limit, the right response might not be "stop." It might be:
- Switch from GPT-4 to GPT-3.5 (cheaper, faster, good enough for this task)
- Reduce the context window from 128K tokens to 16K tokens
- Skip the optional document enrichment step
- Return a cached response instead of a live inference
- Allow the action but flag it for review
This is graceful degradation. It keeps the system running at reduced capability instead of failing hard.
Cycles supports this through its three-way decision model. When the deepest matching budget has caps configured, the system returns `ALLOW_WITH_CAPS`. The caller receives structured constraints and must apply them; the current server does not add or tighten caps automatically as the balance falls.
Implementing this on top of a custom counter requires the counter to return not just "yes" or "no" but also "how much is left" and "what constraints apply." That turns a simple counter into a policy engine. Most teams do not make that investment.
### Maintenance burden: every new service needs the same logic
When one service has a token counter, it works fine.
When five services have token counters, each implemented slightly differently, the system has five potential sources of budget accounting bugs.
Service A uses Redis with INCR. Service B uses PostgreSQL with a row lock. Service C uses an in-memory counter because "it's just a prototype." Service D was supposed to add a counter but the team ran out of time.
The result is inconsistent enforcement, duplicated logic, and fragile coordination. Every new service that makes LLM calls must re-implement the counter pattern, or integrate with whichever shared counter exists, or — most commonly — skip it and hope for the best.
Cycles centralizes runtime authority in one service. Every client integrates through the same protocol. The budget logic lives in one place. New services call the same API. There is one source of truth for budget state.
## Comparison
| | In-App Counter | Cycles |
|---|---|---|
| **Concurrency safety** | Race conditions under parallel access | Atomic reservations — no TOCTOU bugs |
| **Multi-service** | Counter is local to one process or requires custom shared store | Centralized runtime authority accessible from any service |
| **Reservation model** | None — tracks past usage, not in-flight work | Reserve before execution, commit after, release on cancel |
| **Hierarchical scopes** | Flat — one counter, one limit | Nested — tenant → workspace → app → workflow → agent → toolset |
| **Overage policies** | Binary — allow or deny | Three-way — ALLOW, ALLOW_WITH_CAPS, DENY |
| **Maintenance** | Duplicated across services, each with its own bugs | Single integration point, one protocol, one source of truth |
| **Retry handling** | Fragile — retries may double-count or skip counting | Idempotent — retries tied to the same reservation lifecycle |
| **TTL and expiry** | Manual cleanup if at all | Built-in reservation TTL with automatic expiry and release |
| **Audit trail** | Application logs, if instrumented | Structured reservation and commit records |
## The inflection point: when to move from counters to Cycles
Custom counters are not always wrong. They are a valid solution at a certain scale.
The inflection point comes when one or more of these conditions appear.
### Multiple services making LLM calls
Once budget enforcement must span more than one service, a local counter is no longer sufficient. The coordination cost of keeping multiple counters consistent exceeds the cost of adopting a centralized authority.
### Multi-tenant deployment
When different tenants share the same infrastructure and need independent budget limits, the counter must become tenant-aware. Multiplied by hierarchical scopes such as tenant, workspace, and workflow, the counter logic becomes a budget system whether you intended to build one or not.
### Production concurrency
When agents run in parallel — multiple threads, multiple instances, multiple workflows — the TOCTOU race condition becomes a real source of overspend. Solving it correctly with custom code requires careful engineering that is hard to get right and easy to break during refactoring.
### Need for graceful degradation
When the business requires more than hard cutoffs — when "switch to a cheaper model" is the right response instead of "error 403" — a binary counter is no longer expressive enough.
### Compliance or audit requirements
When the organization needs to demonstrate that every LLM call was authorized against a budget, with a clear trail of reservations and commits, ad hoc counters do not provide the necessary structure.
If none of these apply, a custom counter may be all you need. Not every system requires a dedicated runtime authority. A prototype, a single-service application with low concurrency, or an internal tool with one user can work fine with a simple counter.
But if two or more of these conditions are present, the custom counter is likely accumulating correctness debt faster than the team can repay it.
## Migration path
Moving from custom counters to Cycles does not require a big-bang migration.
### Step 1: Deploy in shadow mode
Send Cycles reservation requests with `dry_run: true` alongside your existing counter path. The server returns hypothetical decisions without creating reservations or mutating balances. Log those responses in the application so the two systems can be compared.
Compare the decisions. Does Cycles agree with your counter? Where do they diverge? A divergence may reveal a counter bug, different scope mapping, stale state, or a difference in estimate policy, so investigate it rather than assuming which system is wrong.
See [Shadow Mode Rollout](/how-to/shadow-mode-in-cycles-how-to-roll-out-budget-enforcement-without-breaking-production) for a detailed guide.
### Step 2: Validate scope configuration
Configure Cycles with the same budget limits your counters enforce. Map your counter keys to the standard Cycles scopes: tenant, workspace, app, workflow, agent, and toolset. If one counter is keyed per run, map that run ID to `subjects.workflow`; a run ID in `dimensions` is attribution only.
### Step 3: Enable enforcement on one service
Pick a lower-impact service with simple counter logic and limited concurrency. Switch its protected paths from the custom counter to live Cycles reservations, then monitor long enough to cover representative traffic and failure cases.
### Step 4: Roll out to remaining services
Move each service from its custom counter to Cycles. With each migration, the custom counter code can be removed. The budget logic converges on a single integration point.
### Step 5: Remove the custom counters
Once all services use Cycles, the custom counter code can be deleted. No more duplicated logic, no more inconsistent enforcement, no more race conditions in hand-rolled concurrency handling.
The result is a system where budget authority is centralized, concurrency-safe, and consistent across every protected service that makes LLM calls.
## The build vs buy calculation
Building a custom counter is cheap at first. The initial implementation takes hours.
Maintaining it under production conditions costs more than most teams expect:
- Debugging race conditions that only manifest under load
- Coordinating counter logic across services during refactors
- Adding hierarchical scopes after the fact
- Building reservation semantics on top of a simple increment
- Handling edge cases around retries, crashes, and partial failures
- Explaining to the team why the budget numbers do not add up
Cycles is designed to handle these concerns from the start. It is not a better counter. It is a different primitive — a runtime authority with reservation semantics, hierarchical scopes, and concurrency safety built in.
The question is not whether you can build it yourself. You can.
The question is whether budget accounting is where your team should be spending its engineering time.
## Next steps
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Try the [End-to-End Tutorial](/quickstart/end-to-end-tutorial) — zero to a working budget-guarded LLM call in ten minutes
- [How Much Do AI Agents Actually Cost?](/blog/how-much-do-ai-agents-cost) — per-token pricing across providers and why counting tokens alone isn't enough
# Cycles vs Guardrails AI: Runtime Authority vs Content Safety
Guardrails AI and Cycles both sit in the path of LLM execution.
They both add control. They both can prevent bad outcomes.
But they control different things entirely.
Guardrails AI validates **what the model says**.
Cycles controls **whether the model gets called at all**.
One is about content safety. The other is about runtime authority. They operate at different points in the execution lifecycle, solve different problems, and complement each other cleanly.
> **Run the numbers for your workload:** [Blast Radius Risk Calculator →](/calculators/ai-agent-blast-radius-standalone) — content guardrails filter input/output text; the calculator shows what *actions* the agent could still take that no content filter catches.
## What Guardrails AI does
Guardrails AI is a framework for validating LLM inputs and outputs. It wraps model calls with validators that check whether the response meets defined safety and quality criteria.
Its core capabilities include:
### Output validation
Guardrails checks whether an LLM response meets structural and content requirements. Does the output match a schema? Does it contain required fields? Is the JSON well-formed?
### Content safety rails
Guardrails can detect and filter harmful content — toxicity, bias, personally identifiable information, profanity, or any content that violates a policy. It intercepts unsafe outputs before they reach the user.
### Schema enforcement
When an application expects structured output from an LLM, Guardrails ensures the response conforms to a defined schema. If the output is malformed, Guardrails can retry the call or return a corrected version.
### Prompt injection detection
Guardrails can identify attempts to manipulate the model through adversarial inputs. It adds a layer of defense against prompt injection attacks that try to override system instructions.
### Retry and re-ask logic
When validation fails, Guardrails can automatically retry the LLM call, optionally re-asking with a corrected prompt. This creates a feedback loop that improves output quality without manual intervention.
These are valuable capabilities. Content safety and output quality are real problems that need real solutions.
But none of these capabilities address the question: should this model call happen at all, given what the system has already spent?
## What Cycles does
Cycles supplies runtime budget authority for autonomous agents through a reserve-then-commit lifecycle.
Its core capabilities include:
### Pre-execution budget enforcement
Before an instrumented model call, the host asks Cycles whether the submitted estimate fits the matching ledgers. At a mandatory boundary, a rejected reservation prevents that call from starting. Calls that bypass the integration are outside this guarantee.
### Reserve-then-commit lifecycle
Cycles reserves estimated cost before execution, then commits actual cost afterward. Commit releases any unused portion of the hold. This prevents concurrent submitted estimates from oversubscribing the same matching ledgers; actual usage above an estimate follows the commit-overage policy.
### Concurrency-safe budget tracking
When multiple agent threads or workflows run in parallel, Cycles uses atomic reservations. If $5 remains and two submitted reservations each request $4, at most one can succeed.
### Hierarchical scope enforcement
Budgets can be enforced at multiple standard subject levels simultaneously: tenant, workspace, app, workflow, agent, and toolset. A single reservation checks all applicable populated scopes in one atomic operation. To create a ledger per run, map the run ID to a standard field such as `workflow`; `run` and `action` are not native budget scopes.
### Three-way decisions
Instead of a binary allow/deny, Cycles supports three responses:
- **ALLOW** — budget is sufficient, proceed normally
- **ALLOW_WITH_CAPS** — reservation accepted with operator-configured constraints (for example, use a cheaper model or skip optional steps)
- **DENY** — budget is exhausted, do not proceed
This enables graceful degradation instead of hard failures.
## The key difference
Guardrails AI and Cycles ask fundamentally different questions.
**Guardrails asks:** Is this LLM output safe, correct, and well-formed?
**Cycles asks:** Is this LLM call authorized to execute given the remaining budget?
Guardrails operates on content. It examines what the model produced and decides whether that content should be passed through, corrected, or blocked.
Cycles operates on economics. It examines the budget state and decides whether the model should be invoked at all.
A model call can pass Guardrails validation (the output is safe and well-formed) while failing Cycles enforcement (the budget is exhausted). And vice versa — a call can be authorized by Cycles (budget is available) while being flagged by Guardrails (the output contains PII).
These are independent concerns. Neither subsumes the other.
## Comparison
| | Guardrails AI | Cycles |
|---|---|---|
| **Primary concern** | Content safety and output quality | Budget governance and cost control |
| **When it acts** | After LLM response (output validation) or before call (input validation) | Before LLM call (pre-execution budget check) |
| **What it prevents** | Toxic content, schema violations, prompt injection, PII leakage | Budget overruns, unbounded spend, cost race conditions |
| **Concurrency model** | Per-request validation (stateless) | Atomic reservations across concurrent requests (stateful) |
| **Budget awareness** | None — does not track cost or spend | Core function — reserves, commits, and tracks budget across scopes |
| **Protocol** | Python framework with validators and guards | Open protocol with reserve-commit-release lifecycle |
| **Retry behavior** | Re-asks the model with corrected prompts | Idempotent reservations — retries do not double-spend |
| **Scope** | Per-call input/output validation | Per-tenant, per-workflow, per-agent hierarchical budgets |
| **Degradation** | Can correct or filter outputs | Can downgrade model choice, reduce scope, or deny execution |
## Where Guardrails AI falls short for budget control
Guardrails AI is not designed for cost governance. That is not a criticism — it is a scope observation.
### No cumulative cost tracking
Guardrails validates each call independently. It does not maintain a running total of how much a workflow, run, or tenant has spent. It cannot answer: "Should we stop calling the model because this run has already consumed $8 of its $10 budget?"
### No pre-execution cost check
Guardrails primarily acts on the output side. It checks the response after the model has been called. By then, the cost has already been incurred. Even its input validators do not perform budget checks.
### No reservation semantics
Guardrails has no concept of reserving budget before execution and committing actual cost afterward. It cannot prevent two concurrent calls from exceeding a shared budget because it does not track budgets at all.
### No hierarchical budget scopes
Guardrails does not enforce limits at the tenant, workspace, or workflow level. It operates on individual model calls without cross-call or cross-scope awareness.
### Retries increase cost
When Guardrails re-asks the model after a validation failure, that retry costs money. There is no budget check before the retry. If the model fails validation five times, the system pays for five calls — regardless of whether the budget can absorb them.
## Where Cycles falls short for content safety
Cycles is not designed for content validation. That is equally intentional.
### No output inspection
Cycles does not examine what the model said. It does not know whether the response contains PII, toxic language, or malformed JSON. It authorized the call to happen. What the model produces is outside its scope.
### No schema enforcement
Cycles does not validate whether LLM output matches a required structure. It governs execution economics, not output structure.
### No prompt injection detection
Cycles does not inspect prompts or responses for adversarial manipulation. That is a content-layer concern, not a budget-layer concern.
### No content filtering
Cycles cannot detect or remove harmful content from model responses. It does not operate on content at all.
## Using both together
Guardrails AI and Cycles sit at different points in the execution path. They complement each other naturally.
The flow looks like this:
```
Agent decides to call an LLM
→ Cycles: Is there budget for this call?
→ DENY → Do not call the model. Return a fallback or error.
→ ALLOW_WITH_CAPS → Call a cheaper model or reduce context.
→ ALLOW → Proceed with the intended model.
→ LLM call executes
→ Guardrails: Is this output safe and well-formed?
→ FAIL → Re-ask or return corrected output.
(Each retry also checks Cycles for budget.)
→ PASS → Return output to the caller.
→ Cycles: Commit actual cost. Release unused reservation.
```
This creates two complementary control layers:
1. **Budget check first (Cycles).** Before spending money, verify that the budget allows it. This prevents wasted cost on calls that should never have happened.
2. **Content check second (Guardrails).** After getting a response, verify that it meets safety and quality standards. This prevents unsafe or malformed content from reaching users.
The critical detail is in the retry loop. When Guardrails triggers a re-ask, that retry should also pass through Cycles. Otherwise, repeated validation failures can create unbounded cost — the model keeps getting called, failing validation, and retrying, with no budget check on each retry.
### Example: a customer support agent
Consider an AI agent that handles customer inquiries.
**Without either tool:** The agent calls GPT-4 for every message. A confused customer sends 50 messages in a long conversation. The agent loops through tool calls, retries, and multi-step reasoning. The run costs $30. The output occasionally contains PII from the CRM lookup. Nobody catches either problem until after the fact.
**With Guardrails only:** The agent's outputs are validated for PII and toxicity. Content safety is handled. But the agent still loops through expensive calls without limit. The $30 run still happens.
**With Cycles only:** The agent's budget is capped at $5 per run. After $5, the agent degrades to a cheaper model or stops. Cost is controlled. But the outputs are not checked for PII or safety violations.
**With both:** The agent's budget is capped at $5 per run (Cycles). Each output is validated for PII and safety (Guardrails). Retries triggered by Guardrails are checked against the remaining budget (Cycles). The system is both safe and economical.
## Different problems, different layers
It is tempting to look for one tool that handles everything. That is not how production systems work.
Content safety and runtime authority are independent concerns:
- A safe output can be too expensive.
- A cheap output can be unsafe.
- A well-formed response can come from a run that already exceeded its budget.
- A budget-compliant run can produce toxic content.
Guardrails AI solves the content problem. Cycles solves the cost problem. Together, they give teams control over both what the model says and how much it costs to say it.
Neither tool is optional if you care about both.
## Next steps
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Try the [End-to-End Tutorial](/quickstart/end-to-end-tutorial) — zero to a working budget-guarded LLM call in ten minutes
- [AI Agent Budget Patterns: A Practical Guide](/blog/agent-budget-patterns-visual-guide) — six common budget patterns with trade-offs for each
# Cycles vs Helicone: Enforcement vs Observability and Rate Limiting
Helicone is a popular LLM observability and gateway platform. It logs every model call, tracks cost per request, and offers rate limiting (including cost-based limits). If you're using Helicone, you already have visibility into what your agents spend.
The question is whether visibility and rate limiting are enough — or whether you need cumulative budget enforcement and action-level control.
> **Run the numbers for your workload:** [Cost Calculator →](/calculators/claude-vs-gpt-cost-standalone) — Helicone observes spend; the calculator shows what is *in scope* before any enforcement layer fires.
## What each does
| | Helicone | Cycles |
|---|---|---|
| **Primary role** | Observability + AI gateway | Runtime authority — pre-execution enforcement |
| **Cost tracking** | Automatic, per-request, 300+ models | Per-scope cumulative budget with remaining balance |
| **Rate limiting** | Request-count and cost-per-window (via headers) | Not a rate limiter — enforces per-action budget authority |
| **Budget enforcement** | Cost-based rate limit blocks within a time window | Cumulative budget with atomic reserve-commit lifecycle |
| **Alerts** | Threshold notifications (email, Slack) | Webhook events on budget state transitions |
| **Action control** | No authorization for downstream application tools | Caller-assigned [RISK_POINTS](/glossary#risk-points) budget; host authorizes tools |
| **Multi-tenant** | Per-user/per-property rate limit segmentation | Tenant-scoped API keys with hierarchical budgets |
| **Caching** | Built-in LLM response caching | Not a caching layer |
| **Smart routing** | Cheapest-provider selection | Not a routing layer |
## Where Helicone works well
Helicone's strengths are real:
- **Cost visibility** — automatic cost calculation for 300+ models with session-level attribution
- **Cost-based rate limiting** — `Helicone-RateLimit-Policy: 500;w=3600;u=cents;s=user` caps spend per user per window
- **Caching** — deduplicates identical requests, significantly reducing costs
- **Smart routing** — selects the cheapest provider for equivalent models
- **Configurable alerts** — cost and error threshold notifications via email and Slack
For teams that need visibility and basic cost guardrails, Helicone covers the common case.
## Where the gaps appear
### 1. Window-based vs. cumulative budget
Helicone's cost-based rate limit enforces *spend per time window* (e.g., $5/hour). It does not enforce a cumulative budget state ("you have $47.23 remaining this month"). When the window resets, the limit resets — there's no carry-over, no "remaining balance" concept.
Cycles tracks cumulative budget state with a balance that decreases with each reservation and increases with each release. The budget has an `allocated`, `remaining`, `reserved`, `spent`, and `debt` balance at all times. This is the difference between a rate limit and a budget.
### 2. Rate limit headers vs. persistent budgets
Helicone rate limits are configured per-request via HTTP headers (`Helicone-RateLimit-Policy`) with low-latency enforcement and immediate 429 feedback. However, there's no persistent budget object that lives independently of the requests. If you change the header value, the limit changes. If you forget the header, there's no limit.
Cycles budgets are persistent objects created via the admin API. They exist independently of any request. The protection still depends on the host sending each governed operation through the mandatory reservation boundary.
### 3. Gateway enforcement vs. budget-state events
Helicone alerts are notifications, while its custom request- and cost-based rate limits are enforceable gateway controls that return `429` before an over-limit provider request. These are separate features.
Cycles emits events for selected registered lifecycle transitions. A live reservation without sufficient budget returns an error before protected execution; `ALLOW_WITH_CAPS` is a separately configured decision outcome, not an automatic response at a threshold.
### 4. No action-level control
Helicone controls request volume and cost. It cannot distinguish between a $0.01 search API call and a $0.01 `send_email` tool call. Both cost the same in tokens — but the email has 10,000x the blast radius.
The host can assign [RISK_POINTS](/how-to/assigning-risk-points-to-agent-tools) estimates by consequence and require a reservation before a tool attempt. For example, two 40-point email estimates consume 80 points from a 100-point budget. The host still authorizes the email and ensures every protected attempt is instrumented.
### 5. Segmented window limits, not hierarchical cumulative budgets
Helicone supports rate-limit segmentation by user, organization, custom property, or globally — meaningful isolation for many use cases. But these are window-based limits (reset per time period), not persistent hierarchical cumulative budgets with a reserve-commit ledger.
Cycles provides per-tenant isolation with hierarchical scopes (tenant → workspace → workflow → agent), where each level has its own cumulative budget with `allocated`, `remaining`, `reserved`, `spent`, and `debt` balances — derived atomically across the full scope hierarchy on every reservation.
## Better together: Helicone + Cycles
Helicone and Cycles complement each other. Running both gives you capabilities neither provides alone:
```
Request flow:
Agent decides to act
→ Cycles: "Should this action happen?" (budget authority, RISK_POINTS)
→ Helicone: Check rate-limit policy, route, or serve cache
→ Provider: Execute (or return cached response)
→ Helicone: Log cost and trace
→ Cycles: Commit actual cost, release unused reservation
```
**What this stack gives you:**
| Capability | Who provides it |
|---|---|
| LLM response caching (deduplicate identical calls) | Helicone |
| Cheapest-provider routing | Helicone |
| Pre-execution budget authority | Cycles |
| Caller-assigned action-exposure budget | Cycles; host authorizes actions |
| Cost attribution per trace/session | Helicone |
| Cumulative budget enforcement per tenant | Cycles |
| Rate limiting per time window | Helicone |
| Per-action reserve-commit lifecycle | Cycles |
| Cost anomaly dashboard | Helicone |
| Webhook events for automated response | Cycles |
**Concrete integration scenario:** Helicone's cache deduplicates repeated requests at zero cost — this reduces the total number of actions that even reach Cycles. For uncached requests, Cycles enforces budget authority. Meanwhile, Helicone's per-session cost tracking lets you correlate Cycles' `reservation_id` with trace data for unified debugging. Helicone reduces what you spend. Cycles limits what you're allowed to spend. Together, they form both the optimization and the enforcement layer.
**Another scenario:** Helicone's cost alert fires at 80% of a soft threshold — your team sees the Slack notification. A Cycles live reservation is rejected when sufficient budget is unavailable. `ALLOW_WITH_CAPS` is a separate, operator-configured accepted outcome, not the automatic 100% response. The alert gives you time to intervene; the mandatory reservation boundary prevents the next estimate from being accepted beyond available budget.
## What Cycles does not do
Cycles is not an observability platform, a caching layer, or a router. It doesn't trace requests, deduplicate responses, or select the cheapest provider. If you need those things (and most production stacks do), you need Helicone or a comparable tool alongside Cycles. The reserve-commit lifecycle also adds [~15ms latency per action](/blog/cycles-server-performance-benchmarks) (p50) — negligible against multi-second LLM calls, but present.
## When Helicone alone is enough
- You need cost visibility and analytics more than enforcement
- Per-window rate limiting (e.g., "$5/hour per user") is sufficient
- Your agents don't have side-effecting tools (email, deploy, mutations)
- You don't need persistent cumulative budget tracking
- Single-tenant or simple multi-user segmentation
## When you need Cycles
- You need a cumulative monthly/quarterly budget with a "remaining balance"
- Your agents have tools with side effects that need action-level control
- You need multi-tenant budget isolation with hierarchical scopes
- You need atomic budget enforcement under concurrent agent load
- You need delegation attenuation for multi-agent systems
## Sources
Feature claims verified against [Helicone's custom rate-limit documentation](https://docs.helicone.ai/features/advanced-usage/custom-rate-limits) on July 24, 2026. Cycles claims are based on v0.1.25. These tools evolve quickly—check the linked docs for the latest.
## Related
- [Cycles vs LLM Proxies and Observability Tools](/blog/cycles-vs-llm-proxies-and-observability-tools) — broader comparison
- [Cycles vs LangSmith](/concepts/cycles-vs-langsmith) — similar observability comparison
- [What Is Runtime Authority](/blog/what-is-runtime-authority-for-ai-agents) — the enforcement model
# Cycles vs LangSmith: Budget Layers Compared
LangSmith is one of the most widely adopted observability platforms for LLM applications. If you're building with LangChain or LangGraph, you're probably already using it — or evaluating it.
LangSmith is no longer only an observability comparison. Its private-beta LLM Gateway can proxy supported provider calls and enforce spend policies. Cycles remains an application-boundary budget service with reserve-commit semantics. Understanding the enabled LangSmith surface and the traffic each boundary covers prevents both gaps and redundancy.
> **Run the numbers for your workload:** [Cost Calculator →](/calculators/claude-vs-gpt-cost-standalone) · [Blast Radius Risk Calculator →](/calculators/ai-agent-blast-radius-standalone) — observability records what happened; the calculators show what *will* happen at your token volume and action profile.
## What each does
| | LangSmith | Cycles |
|---|---|---|
| **When it acts** | Observability records execution; LLM Gateway evaluates policy before a proxied model call | Application reserves before protected execution and settles afterward |
| **What it answers** | "What happened?" and, in Gateway, "Does this provider request fit the spend policy?" | "Can this submitted amount be held against every matching ledger?" |
| **Core mechanism** | Tracing, evaluation, and private-beta provider proxy policies | Reserve → commit → release |
| **Cost control** | Hard Gateway caps by organization, workspace, API key, or user over hourly through monthly windows | Atomic budgets across populated tenant → workspace → app → workflow → agent → toolset scopes |
| **Application actions** | Traces tool runs; Gateway governs supported LLM-provider traffic | Can meter caller-assigned tool exposure; host still authorizes tools and arguments |
| **Settlement** | Gateway tracks spend against caps | Explicit estimate hold, best-known actual commit, and release |
| **Deployment** | LangSmith-managed Gateway is in private beta | Self-hosted service and open protocol |
## The fundamental difference
LangSmith Observability tells you what an agent did across traces and runs. That information drives optimization, debugging, evaluation, and capacity planning.
When traffic is routed through the private-beta LLM Gateway, LangSmith can also evaluate hard spend policies on each incoming provider request. Its documented scopes are organization, workspace, API key, and user, with hourly, daily, weekly, or monthly windows. A blocked request receives `402`, and the violation is attached to a trace.
Cycles operates at the boundary the application chooses. Before a protected LLM or tool call executes, the host requests an atomic hold against matching ledgers. If the reservation fails and the host honors that result, the protected operation does not run. If it succeeds, the host commits best-known actual usage or releases an unused hold.
## Where the boundaries differ
### Provider gateway versus application boundary
LangSmith's Gateway protects LLM calls routed through that proxy and currently documents OpenAI, Anthropic, Bedrock, Baseten, Fireworks, Gemini, and Vertex AI providers. Calls that bypass the Gateway, plus arbitrary application tools such as refunds, emails, database writes, and deployments, need another mandatory control point.
Cycles is provider-neutral and can sit around any instrumented operation. Coverage is not automatic: bypass paths remain outside its budget boundary, and action authorization remains application logic.
### Spend policy versus reserve-commit
LangSmith documents real-time spend tracking and a pre-execution block when a proxied request would cross a Gateway cap. Cycles exposes a different lifecycle: reserve an estimate atomically, hold it while work is in flight, then commit actual usage or release it. That distinction matters when many calls start concurrently or when an application needs to account for work that can fail after admission.
Do not infer one product's concurrency or overage semantics from the other. Validate Gateway behavior against the private-beta version you are using; configure Cycles estimation and commit-overage policy for the application boundary you own.
### Budget exposure is not tool permission
LangSmith can trace a `send_email` run. A host can separately require a Cycles `RISK_POINTS` reservation before each authorized attempt. Neither a Gateway spend policy nor a risk-point balance decides whether a recipient, tool, or argument is authorized. Keep identity, credentials, allowlists, and argument validation at the host or gateway that executes the action.
## Where Cycles stops
Cycles does not replace LangSmith. It has no:
- **Trace visualization** — no flame graphs, no chain-of-thought replay
- **Evaluation framework** — no LLM-as-judge, no dataset management
- **Prompt management** — no prompt hub, no versioning, no sharing
- **Prompt debugging** — no A/B testing, no dataset-driven evaluation
- **Latency profiling** — no per-step timing breakdown
These are observability concerns. Cycles is not an observability tool.
## How they work together
The strongest production setup uses both:
```
Application action
→ host authorization
→ Cycles reserve (application-scope budget)
→ optional LangSmith LLM Gateway (provider spend policy, redaction, credentials)
→ provider
→ Cycles commit/release + LangSmith trace
```
### Practical example
A customer support agent built with LangChain:
1. **Cycles** checks budget before each instrumented LLM call and tool invocation. If a matching budget has `max_tokens` configured, an accepted request can return `ALLOW_WITH_CAPS`; if a live reservation lacks budget, it returns an error such as `409 BUDGET_EXCEEDED`. The application applies caps or handles the denial.
2. **LangSmith Observability** traces the instrumented chain execution. If the team also uses the private-beta Gateway, provider calls pass its spend and data policies before being forwarded.
The overlap is provider spend enforcement; the differences are boundary, scope vocabulary, lifecycle, deployment, and observability depth. Cycles does not visualize a chain execution, while LangSmith's provider Gateway does not automatically govern every application tool.
### Feeding Cycles data into LangSmith
The commit metrics (`StandardMetrics` — tokens, latency, model version) attached to each commit are available through the Cycles API. Teams that want unified dashboards can:
- Tag LangSmith traces with the Cycles `reservation_id` for cross-referencing
- Use LangSmith's custom metadata to include Cycles decision outcomes (`ALLOW`, `DENY`, `ALLOW_WITH_CAPS`)
- Build alerting rules in LangSmith that flag traces where Cycles returned `ALLOW_WITH_CAPS` — indicating that configured caps applied
## Decision guide
**Use LangSmith when you need to:**
- Debug why an agent produced a bad response
- Evaluate response quality across datasets
- Profile latency across chain steps
- Track cost attribution across runs and users
- Enforce supported-provider spend policies through its private-beta LLM Gateway
**Use Cycles when you need to:**
- Reserve estimated exposure atomically before protected application work
- Use the protocol's tenant, workspace, app, workflow, agent, and toolset scope hierarchy
- Settle best-known actual usage after execution
- Meter non-LLM work and caller-assigned action exposure
- Return configured caps for the host to apply
**Use both when you need to:**
- Run agents in production with both visibility and enforcement
- Correlate rich execution traces with reserve-commit budget records
- Debug why an agent was denied (LangSmith trace + Cycles decision)
- Layer provider-gateway policies under broader application budgets
## Key points
- **Compare enabled surfaces.** LangSmith Observability is retrospective; its private-beta LLM Gateway adds pre-execution provider spend policies.
- **Compare boundaries, not slogans.** Gateway policies cover routed provider traffic. Cycles covers instrumented application paths and exposes reserve-commit settlement.
- **Authorization remains separate.** Neither cost control grants permission to invoke an application tool.
- **Use correlated records.** LangSmith traces and Cycles reservation IDs can explain both execution and budget treatment.
LangSmith behavior was rechecked on July 24, 2026 against the official [LLM Gateway overview](https://docs.langchain.com/langsmith/llm-gateway) and [spend-policy documentation](https://docs.langchain.com/langsmith/llm-gateway-spend-policies). The Gateway is documented as private beta, so verify availability and semantics for your account.
## Next steps
- [From Observability to Enforcement](/concepts/from-observability-to-enforcement-how-teams-evolve-from-dashboards-to-budget-authority) — the evolution from dashboards to runtime authority
- [Cycles vs LLM Proxies and Observability Tools](/blog/cycles-vs-llm-proxies-and-observability-tools) — how Cycles complements LiteLLM, Portkey, Helicone, and Langfuse
- [Integrating with LangChain](/how-to/integrating-cycles-with-langchain) — add Cycles to your LangChain application
- [Integrating with LangGraph](/how-to/integrating-cycles-with-langgraph) — budget governance for LangGraph workflows
# Cycles vs LiteLLM: Application and Gateway Budgets
LiteLLM routes model calls across providers with fallback, enforces gateway budgets, and documents budget reservations enabled by default. It also provides agent iteration and session-spend limits. Advance reservation alone is therefore not a Cycles distinction.
The useful comparison is the accounting boundary. Cycles exposes a caller-facing reservation lifecycle for operations the application instruments, so model calls, paid APIs, and other tool operations can consume shared application budgets. LiteLLM may already cover your workload when its gateway paths, scopes, and enforcement semantics match your requirements.
## What each does
| | LiteLLM | Cycles |
|---|---|---|
| **Primary role** | Gateway routing, fallback, budget enforcement, and cost tracking | Budget accounting for instrumented application operations |
| **Coverage** | Routed model calls; agent controls and MCP tool-cost tracking | Any operation the host protects with the lifecycle, including calls outside a gateway |
| **Budget scopes** | Keys, teams, users, customers, and other gateway dimensions; agent/session controls | Tenant → workspace → app → workflow → agent → toolset; a workflow value can identify a run |
| **Reservations** | Gateway estimates, reserves before supported provider calls, and reconciles actual cost | Caller submits an estimate, reserves before protected work, and commits actual usage |
| **Concurrency** | Reservations account for in-flight estimates; coverage and failure behavior depend on route and configuration | Atomic holds across matching provisioned ledgers; actual overages follow the configured policy |
| **Rate and iteration limits** | RPM/TPM and agent session iteration limits | Cumulative budgets; not an RPM/TPM limiter or automatic iteration counter |
| **Tool governance** | [MCP permissions](https://docs.litellm.ai/docs/mcp_control) apply to routed tools | Caller-assigned [RISK_POINTS](/glossary#risk-points) budgets; host authorizes tools and arguments |
| **Integration** | Route supported traffic through the gateway and configure controls | Instrument each protected operation and submit its scopes, estimate, and settlement |
## LiteLLM budget reservations and agent budgets
LiteLLM's [budget reservation documentation](https://docs.litellm.ai/docs/proxy/users#budget-reservation) describes this sequence:
1. Estimate request cost from the request body and model pricing.
2. Reserve capacity against the applicable budget.
3. Reject before the provider call if the reservation would exceed the budget.
4. Replace the reservation with actual cost after the response is priced.
Reservations are enabled by default. Routes without token pricing fall back to recorded-spend enforcement, and batch submission cannot reserve the full job cost. Configured budgets require a database; the separate `fail_closed_budget_enforcement` setting addresses degraded or stale budget state. Check those deployment details when evaluating a hard ceiling.
LiteLLM also documents [`max_iterations` and `max_budget_per_session`](https://docs.litellm.ai/docs/a2a_iteration_budgets). These require session attribution; the agent setup uses `require_trace_id_on_calls_by_agent`. The session-budget guide describes checking accumulated spend before calls and adding cost after successful LLM calls, with counters expiring after one hour by default. Do not assume the session limiter has the same reservation semantics as every gateway budget.
Tool costs are also in scope for LiteLLM: [MCP cost tracking](https://docs.litellm.ai/docs/mcp_cost) supports fixed tool/server prices and custom post-call cost hooks. That establishes tool accounting, not an identical pre-execution reservation guarantee for every MCP operation. Evaluate the actual route and configured controls.
## Where Cycles adds an application budget boundary
### Shared accounting across protected services
A workflow might call a model through LiteLLM, query a paid data API directly, and dispatch a metered background job. Cycles can reserve each operation against the same workflow and tenant ledgers, even when those operations use different transports or never enter the gateway.
This coverage is explicit. The host must protect every relevant dispatch, supply an estimate in a consistent unit, and settle actual usage. Cycles does not automatically discover tool calls, provider prices, or work that bypasses the integration. The [cost-estimation guide](/how-to/how-to-estimate-exposure-before-execution-practical-reservation-strategies-for-cycles) explains this responsibility.
### Application scope hierarchy
Cycles derives scopes in the order tenant → workspace → app → workflow → agent → toolset. Only submitted levels are included, and only provisioned budgets participate. A child operation can consume both a shared tenant or workflow budget and a narrower agent budget. At least one derived scope must have a budget.
LiteLLM already provides multiple budget identities and agent/session controls. The evaluation question is whether those scopes express the application's required shared ceilings. Cycles' hierarchy does not automatically discover delegation, transfer parent allocations, or enforce child permissions; the orchestrator supplies the scopes and authorizes the work.
### Explicit lifecycle and overage handling
Cycles exposes reserve, commit, release, and reservation extension to the caller. The host reserves before dispatch, commits after work, and releases a hold when work is canceled before any usage occurs. Long-running work can [extend its reservation lease](/protocol/reservation-ttl-grace-period-and-extend-in-cycles).
Atomic reservations protect estimated capacity. They do not guarantee that actual external charges stay below an estimate. Under the [authoritative protocol](/cycles-protocol-v0.yaml), `REJECT` rejects an over-estimate commit, which can leave an accounting gap for work already performed. The default `ALLOW_IF_AVAILABLE` charges the delta up to available capacity and marks an uncovered overage as over-limit, without creating debt. `ALLOW_WITH_OVERDRAFT` can record debt subject to its specified limits. Choose and test the policy explicitly; debt is not the outcome of every overage.
### Caller-assigned exposure budgets
The host can assign [RISK_POINTS to tool attempts](/how-to/assigning-risk-points-to-agent-tools) and meter their cumulative exposure separately from money. This can apply to protected operations outside the gateway. It does not establish whether a tool or its arguments are authorized; that remains application policy.
## Using LiteLLM and Cycles together
For a protected model call, both layers can enforce a budget:
```text
Application authorizes the operation
→ Cycles: reserve estimate against application scopes
→ LiteLLM: apply gateway policy, reserve supported cost, and route
→ Provider: execute if admitted
→ LiteLLM: reconcile its reservation with actual cost
→ Application: commit the operation's actual usage to Cycles
```
For a direct paid API or background job, the application uses the same Cycles lifecycle around that service's dispatch. If LiteLLM rejects before execution and no usage occurred, release the Cycles hold. If work incurred cost before failing, settle that usage; a failed response does not necessarily mean zero cost.
Each ledger serves its own budget boundary. Recording one model charge in both systems does not mean the provider charged twice; do not sum those records as separate expenses. Retries and fallbacks that incur additional charges must be included in the application's estimate and settlement.
Cycles can return configured `ALLOW_WITH_CAPS` constraints, which the host can map to a cheaper LiteLLM route. Cycles does not select the model or automatically add caps when balance becomes low.
## A workload to evaluate across several services
The following is an evaluation design with synthetic prices, not a measured product benchmark or a packaged runnable demo. Use a provisioned Cycles stack from the [end-to-end tutorial](/quickstart/end-to-end-tutorial), a configured LiteLLM gateway, and deterministic service fixtures with request logs. Record exact image versions, pricing, budget settings, and commands with any published results.
Configure a shared tenant budget of $10 and a workflow budget of $1 using `USD_MICROCENTS` (100,000,000 units per dollar). Submit the same tenant/workflow on all operations and use separate agent identifiers for concurrent workers. Begin each trial with fresh ledgers and fixed costs equal to estimates:
| Operation | Service path | Estimate and actual cost |
|---|---|---|
| Model request | Through LiteLLM to a provider fixture | $0.20 |
| Paid search | Direct HTTP service fixture | $0.30 |
| Document processing | Separate metered worker fixture | $0.40 |
Reserve all three operations before permitting any to finish. Their holds total $0.90. While they remain active, attempt another $0.20 model operation: its Cycles reservation should be rejected, with no corresponding LiteLLM or provider invocation. Commit the first three operations and verify $0.90 charged at both workflow and tenant scopes, zero remaining holds, and $0.10 workflow capacity left. The tenant charge is the same consumption aggregated at an ancestor, not another $0.90 of expense.
In separate fresh trials, cancel the search before dispatch and verify its $0.30 hold becomes available; replay an identical commit with the same idempotency key and verify no duplicate debit; then set an actual cost above its estimate and inspect the configured overage policy. A replay-safe Cycles commit does not make a service dispatch idempotent: use the service's own mechanism for that.
For the LiteLLM baseline, keep native reservations enabled, configure applicable gateway and agent/session budgets, and include supported MCP accounting when routing tools through MCP. Report exactly which operations each configuration covers. A direct service outside the gateway demonstrates a coverage boundary; it does not prove LiteLLM cannot govern a version of that operation routed through its supported interfaces.
## Choosing the boundary
LiteLLM alone may cover the budget requirement when all governed operations use supported gateway paths and its scopes, session controls, and enforcement behavior fit the workload. Concurrency, multiple tenants, or multiple agents alone do not establish a need for Cycles.
Consider adding Cycles when protected operations across several services need shared application ledgers, a caller-managed lifecycle, or separately metered exposure budgets. Account for the integration work: the host must enforce the boundary, estimate usage, handle leases and failures, and settle charges. Cycles does not provide provider routing, failover, caching, or application authorization.
## Sources
LiteLLM claims checked against its [budget documentation](https://docs.litellm.ai/docs/proxy/users#budget-reservation), [agent iteration budgets](https://docs.litellm.ai/docs/a2a_iteration_budgets), and [MCP cost tracking](https://docs.litellm.ai/docs/mcp_cost) on September 4, 2026. These are documentation claims, not results from testing a pinned LiteLLM deployment. Cycles lifecycle and scope claims follow the repository's [authoritative YAML specification](/cycles-protocol-v0.yaml); verify the deployed implementation against that contract.
## Related
- [Cycles vs LLM Proxies and Observability Tools](/blog/cycles-vs-llm-proxies-and-observability-tools) — gateway, application budget, and tracing boundaries
- [What Is Runtime Authority](/blog/what-is-runtime-authority-for-ai-agents) — the enforcement model
- [How Teams Control AI Agents Today](/blog/how-teams-control-ai-agents-today-and-where-it-breaks) — matching controls to application boundaries
# Cycles vs OpenRouter: Runtime Authority vs Routing with Guardrails
OpenRouter is an LLM routing gateway that provides unified access to many models and providers. Its current controls include workspace budgets, guardrails assignable across workspace/member/key boundaries, model and provider restrictions, zero-data-retention policies, prompt-injection filters, and sensitive-data controls.
If all protected spend flows through OpenRouter, those controls provide real preflight enforcement. The architectural question is whether gateway-level inference controls cover the same boundary as your agent workload.
> **Run the numbers for your workload:** [Cost Calculator →](/calculators/claude-vs-gpt-cost-standalone) — OpenRouter routes; the calculator shows what *cheaper-model routing* alone saves vs hard per-tenant budget enforcement.
## What each does
| | OpenRouter | Cycles |
|---|---|---|
| **Primary role** | LLM router — model selection, provider aggregation | Runtime authority — pre-execution enforcement |
| **Budget model** | Workspace spend limits for daily, weekly, monthly, and lifetime intervals; member/key guardrails | Tenant and subject scopes such as workspace, app, workflow, agent, and toolset |
| **Enforcement** | Preflight gateway check; already-dispatched requests can cause slight overage | Atomic estimate reservation before protected work, followed by actual-cost commit |
| **Coverage** | Requests routed through the OpenRouter inference gateway | Any application operation explicitly instrumented by the host |
| **Model control** | Model/provider allowlists and routing policies | Returns configured cap fields; the host maps and enforces them |
| **Action control** | No native authorization for downstream application tools | Caller-assigned [RISK_POINTS](/glossary#risk-points) budget; application authorization remains separate |
| **Multi-tenant** | Organization, workspace, member, and key controls | Tenant-scoped keys plus subject-scoped budgets |
| **Budget hierarchy** | Workspace interval budgets and inherited/assigned guardrails | Deepest matching configured subject scope |
| **Alerts** | Usage dashboard + key credit/usage introspection | Webhook events on budget state transitions (programmatic, PagerDuty/Slack) |
| **Concurrency behavior** | Preflight enforcement; in-flight inference may slightly exceed a limit | Reservation atomically consumes available budget before work starts |
## Where OpenRouter's guardrails work well
OpenRouter's guardrails system provides:
- **Workspace budgets** for daily, weekly, monthly, and lifetime spend
- **Guardrail assignment and inheritance** across workspaces, members, and keys
- **Model and provider restrictions**
- **Data controls** including zero-data-retention and sensitive-information policies
- **Prompt filters** including regex-based prompt-injection controls
- **Hard enforcement** — requests are rejected when the limit is reached
For teams routing all inference through OpenRouter, this provides meaningful cost and data-policy enforcement at the gateway.
## Where the gaps appear
### 1. Inference workspaces vs. application execution scopes
OpenRouter workspace budgets are shared across requests and can enforce daily, weekly, monthly, and lifetime limits. Member and key guardrails add finer access controls. This is meaningful governance for centrally managed inference.
The boundary is still OpenRouter inference. An agent workflow may also pay for search, browsers, sandboxes, SaaS APIs, or database operations, and may need separate limits per workflow or run even when calls share one gateway workspace. Cycles can apply budgets to those application-defined scopes and operations, provided the host instruments them.
### 2. No action-level control
OpenRouter controls inference requests, models, providers, prompts, and data policies. It does not authorize downstream application tools or their side effects.
For example, a host can assign a `RISK_POINTS` estimate to each email attempt and require a Cycles reservation before invoking the email provider. Cycles bounds the submitted cumulative exposure; the host still decides whether the email is authorized and enforces the tool call.
### 3. No graduated enforcement or programmatic alerts
OpenRouter offers dashboard-level usage alerts and per-key activity logs. But enforcement is binary: under the cap (allowed) or over the cap (rejected). There's no graduated middle ground — no "proceed but with constraints" response, no threshold-triggered webhook events for programmatic automation.
Cycles provides [three-way decisions](/glossary#three-way-decision): ALLOW, ALLOW_WITH_CAPS (proceed with constraints like model downgrade or tool restrictions), and DENY. Plus webhook events on budget state transitions (`budget.exhausted`, `budget.over_limit_entered`) that integrate with PagerDuty, Slack, and automated remediation pipelines.
### 4. Preflight spend checks vs. reserve-commit
OpenRouter checks workspace spend before routing, but requests already in flight complete. Its documentation notes that actual workspace spend can therefore slightly exceed a limit before the next request is blocked.
Cycles [reserves an estimate before the action](/blog/what-is-runtime-authority-for-ai-agents) and commits the actual amount afterward. Concurrent reservations atomically consume available capacity. Commit overages follow the configured overage policy and may be rejected, charged from remaining capacity, or recorded as debt.
### 5. Gateway-only coverage
OpenRouter can only evaluate traffic that reaches its gateway. Cycles is provider-independent and unit-independent, so the same budget protocol can cover model calls from multiple gateways alongside explicitly instrumented non-LLM operations. Cycles is not a rate limiter and does not discover uninstrumented work.
### 6. No delegation attenuation
When agent A spawns sub-agent B via an LLM call, OpenRouter sees both as independent requests from the same key. There's no way to enforce that B has a smaller budget than A, or that B can only access a subset of A's tools.
Cycles supports [authority attenuation](/blog/agent-delegation-chains-authority-attenuation-not-trust-propagation) as an application pattern: provision a narrower child agent ledger and submit both the shared ancestor and child scopes on every protected call. Cycles does not transfer balances at handoff; action masks and delegation-depth limits remain orchestration logic.
## Better together: OpenRouter + Cycles
OpenRouter and Cycles operate at different layers. Running both gives you capabilities neither provides alone:
```
Request flow:
Agent decides to act
→ Cycles: "Should this action happen?" (budget authority, RISK_POINTS)
→ OpenRouter: check workspace/guardrail policy, then route the model call
→ Provider: Execute the call
→ OpenRouter: Track inference usage
→ Cycles: Commit actual cost, release unused reservation
```
**What this stack gives you:**
| Capability | Who provides it |
|---|---|
| Unified access to hundreds of models | OpenRouter |
| Automatic provider selection and pricing | OpenRouter |
| Pre-execution budget authority | Cycles |
| Caller-assigned action-exposure budgets | Cycles; host authorizes the action |
| Workspace interval budgets and key/member guardrails | OpenRouter |
| Hierarchical tenant/workflow/agent budgets | Cycles |
| Model and provider allowlists | OpenRouter |
| Tool allowlists and denylists | Cycles returns configured cap fields; the host enforces them |
| Credit management | OpenRouter |
| Delegation attenuation for sub-agents | Cycles (pattern via hierarchical scopes) |
**Concrete integration scenario:** OpenRouter provides access to many models through a single API. Cycles decides whether an instrumented action can reserve against the configured budget. If the deepest matching budget supplies `ALLOW_WITH_CAPS`, your application can map a returned cap to a cheaper OpenRouter model. OpenRouter handles routing; Cycles handles the budget reservation. The current Cycles server neither infers a risk profile nor adds caps automatically as the balance falls.
**Another scenario:** OpenRouter guardrails restrict a key to lower-cost models. The application authorizes email but not deploy, assigns each email a caller-defined `RISK_POINTS` amount, and requires a Cycles reservation before sending. OpenRouter enforces model access, the application enforces tool access, and Cycles bounds the submitted email exposure.
OpenRouter selects the model and provider. Cycles decides whether the configured budget can cover the submitted action estimate. The host makes and enforces the broader authorization decision. The layers are complementary, not competing.
## What Cycles does not do
Cycles is not a router or model aggregator. It doesn't provide access to hundreds of models from a single API, handle provider selection, or manage credits across providers. If you need unified multi-model access (and most teams using OpenRouter do), you need OpenRouter or a comparable tool alongside Cycles. The reserve-commit lifecycle adds [~15ms latency per action](/blog/cycles-server-performance-benchmarks) (p50) and requires cost estimation upfront — the estimate can be wrong, and overages are tracked as debt rather than prevented.
## When OpenRouter alone is enough
- All your agents do is make LLM calls (no side-effecting tools)
- Workspace budgets and assigned guardrails match the required organizational boundaries
- Slight overage from already-dispatched inference requests is acceptable
- You don't need graduated enforcement (just hard allow/deny)
- Single-team deployment without multi-tenant isolation needs
## When you need Cycles
- Agents have tools with side effects (email, deploy, database mutations)
- You need hierarchical budgets (org → team → workspace → agent)
- You need atomic budget enforcement under concurrent agent load
- You need graduated enforcement (ALLOW_WITH_CAPS for graceful degradation)
- Multi-agent delegation chains requiring authority attenuation
- Webhook events for operational alerting and automated response
## Sources
Feature claims verified against OpenRouter's [guardrails](https://openrouter.ai/docs/guides/features/guardrails) and [workspace budgets](https://openrouter.ai/docs/guides/features/workspaces/workspace-budgets) documentation on July 24, 2026. Cycles claims are based on v0.1.25. These tools evolve quickly—check the linked docs for the latest.
## Related
- [Cycles vs LLM Proxies and Observability Tools](/blog/cycles-vs-llm-proxies-and-observability-tools) — broader comparison
- [Cycles vs LiteLLM](/concepts/cycles-vs-litellm) — similar proxy comparison
- [What Is Runtime Authority](/blog/what-is-runtime-authority-for-ai-agents) — the enforcement model
# Cycles vs Provider Spending Caps: Why Platform Limits Are Not Enough
Every major LLM provider offers controls that affect cost or capacity.
Depending on the vendor and account type, those controls can include soft budget alerts, prepaid credits, model rate limits, project or workspace attribution, throughput quotas, and billing automation.
These controls are useful. They can improve visibility, constrain throughput, or stop provider access when a credit or account limit is reached.
They do not automatically create a per-run or per-tool budget model inside your application.
The relevant question is whether their scope, timing, and failure behavior match the boundary your agent application needs to enforce.
> **Run the numbers for your workload:** [Cost Calculator →](/calculators/claude-vs-gpt-cost-standalone) — use the calculator to model workload cost, then decide which controls belong at the provider, gateway, and application boundaries.
## What provider caps offer
Provider spending caps vary by vendor, but the general pattern is consistent.
### OpenAI spend alerts, hard spend limits, and billing controls
OpenAI supports monthly spend alerts and optional hard spend limits at organization and project scope. Alerts notify while traffic continues. When tracked spend reaches an applicable hard limit, affected requests return `429 insufficient_quota`; OpenAI notes that enforcement is not instantaneous, so recorded spend can slightly exceed the configured amount. Projects also support model-specific rate limits and model access controls. Prepaid billing is separate and can stop API access when credits are exhausted, although its cutoff can also be delayed.
### Anthropic credits, usage tiers, and workspaces
Anthropic bills API usage through prepaid usage credits and stops API access when those credits run out. Its Console reports cost and usage by workspace, model, and API key, while organization usage tiers impose spend and rate limits. Auto-reload settings can change whether the prepaid balance behaves like a fixed ceiling.
### Google Cloud budget alerts
Google Cloud budgets are notification-oriented and do not automatically cap Vertex AI spend. Quotas constrain capacity rather than dollars; for newer generative models, Dynamic Shared Quota has no customer-configured predefined usage limit. Provisioned Throughput provides a separate fixed-capacity purchasing model.
### AWS Bedrock service quotas
AWS provides Bedrock service quotas that constrain request or token throughput. AWS Budgets is a separate billing service with alerts and configurable actions; those controls do not inherently represent an individual agent run or application tenant.
### The common thread
The exact behavior differs by product and plan, but provider-native controls generally share several boundaries:
- They govern traffic or billing inside one provider.
- Their identities are provider projects, workspaces, accounts, keys, or cloud projects—not necessarily your application's tenant and run hierarchy.
- Budget reporting and hard-stop semantics vary; a field labeled “budget” may be an alert threshold rather than a cap.
- Rate and throughput quotas bound request volume or capacity, not arbitrary application-side side effects.
They remain valuable controls. The gap appears when the application needs a cumulative budget for a business-defined scope or for work that is not itself a provider request.
The problem starts when teams need more than basic protection. The single-provider point in particular has its own structural argument — see [Agents Are Cross-Cutting. Your Controls Aren't.](/blog/agents-are-cross-cutting-your-controls-arent) for why a control that lives inside one provider can't reach across an agent that spans many.
## Why provider caps are not sufficient
### Provider periods are not application runs
Many provider cost controls use calendar windows, credit balances, usage tiers, or throughput intervals.
But autonomous agents operate in runs. A single agent run might take 30 seconds and make 15 LLM calls. Another run might take 4 hours and make 300 calls. The cost difference between these runs can be orders of magnitude.
A provider-level monthly threshold does not, by itself, express: "This run may consume at most $5 of the application budget." Your application or gateway needs a run identity and an enforcement rule for that boundary.
Without that finer-grained boundary, a single runaway run can consume a significant share of a broader provider allowance even when the provider control behaves exactly as documented.
### Provider identities may not match application tenants
Provider controls may apply at an organization, project, workspace, cloud-project, model, or key scope. Those scopes can help isolate workloads, but they do not automatically map shared credentials to your application's customer, workflow, run, or tool identities.
If a multi-tenant platform sends every customer's agent traffic through one shared provider identity, the provider cannot infer the application's per-customer budgets. A team can create separate provider projects, workspaces, or keys where supported, but that mapping is an application design choice rather than an automatic tenant model.
For example, consider an illustrative platform with 50 tenants sharing one provider project and a $50,000 soft monthly threshold. If one tenant's agent consumes $8,000, provider reporting can attribute the spend to the shared project but cannot infer the platform's tenant boundary unless the platform supplies a distinct provider identity or enforces that boundary elsewhere.
### Delayed enforcement
Budget dashboards, billing exports, and alerts are not the same as an in-process admission decision. Providers document different reporting and cutoff behavior. OpenAI now distinguishes soft spend alerts from optional hard organization/project spend limits, and documents that hard-limit enforcement is not instantaneous.
If an application polls those reporting surfaces and reacts later, work can continue between the underlying usage and the application's response. Request-time rate limits or exhausted-credit checks are different controls and should not be described as post-hoc.
### No pre-execution check
Providers can reject a request at their own boundary because of rate, credit, or account limits. What they generally do not expose is an application-defined reserve-commit lifecycle that asks, "Does this tenant's current run have enough of this budget unit for the estimated operation?" and holds that amount while the work is in flight.
For calls instrumented through Cycles, the application reserves its submitted estimate before execution. If the reservation is denied and the caller honors that denial, that protected call is not sent. Traffic that bypasses the integration is outside this guarantee.
### No graceful degradation
When a hard provider limit rejects a request, the provider does not know which application-specific fallback is safe. The application can still route to another model, use a cache, or reduce work, but it must implement that policy.
Production systems need nuance:
- Switch to a cheaper model when budget is low
- Reduce context window size
- Skip optional enrichment steps
- Serve cached responses instead of live inference
- Degrade gracefully for low-priority workflows while keeping high-priority ones running
Cycles can return `ALLOW`, configured `ALLOW_WITH_CAPS`, or `DENY` for submitted operations. The caller must translate caps into behavior—such as a cheaper route or smaller context—and must authorize the action separately.
### Multi-provider blind spots
Most teams do not use a single LLM provider.
A typical production stack might include:
- OpenAI for GPT-4 and embeddings
- Anthropic for Claude
- Google for Gemini
- A local model for low-latency classification
Each provider tracks its own usage independently. None of them know about spend on the other providers.
A team that has budgeted $500 per day across all providers has no single place to enforce that limit. OpenAI knows about OpenAI spend. Anthropic knows about Anthropic spend. Neither knows the total.
Cycles can account for submitted estimates across providers when every relevant path is instrumented into the same budget. The budget boundary is defined by the application, not inferred from provider billing.
## Comparison
| | Provider controls | Cycles |
|---|---|---|
| **Granularity** | Vendor-dependent: project/workspace/account windows, credits, or quotas | Submitted operation against configured tenant and subject scopes |
| **Scope** | Provider organization, project, workspace, cloud project, model, or key | Tenant plus caller-supplied subject hierarchy |
| **Enforcement timing** | Vendor-dependent: soft alert, request-time quota, credit check, or billing action | Pre-execution reservation for instrumented work |
| **Multi-provider** | One provider's traffic and billing | Shared budget only when callers submit all relevant provider paths |
| **Degradation** | Provider rejection or provider-specific policy; application chooses fallback | `ALLOW`, configured `ALLOW_WITH_CAPS`, or `DENY`; caller applies caps |
| **Protocol** | Vendor-specific dashboard and API | Open protocol with reserve-commit-release lifecycle |
| **Concurrency handling** | Vendor-specific | Atomic reservation mutation across the matching Cycles budget scopes |
| **Per-tenant enforcement** | Possible when provider identities and policies map to application tenants | Tenant-scoped keys and caller-supplied subject scopes |
| **Retry awareness** | Provider billing and idempotency semantics vary | Reusing the same Cycles idempotency key and request body deduplicates the budget mutation |
## The delay problem in detail
Reporting delay matters when an application treats a dashboard, export, or alert as its enforcement loop.
Consider an application that reacts only to a hypothetical usage report delayed by 60 seconds. Its agent makes calls at one per second, each assumed to cost $0.10, and its application threshold is $100.
At second 1,000, the agent has spent $100. But the polled report reflects spend as of second 940, so the application has not reacted.
The agent makes 60 more calls before the cap catches up. That is $6 of overspend — a 6% overrun.
Now increase the call rate. Five calls per second, each costing $0.50. At the same 60-second delay, that is 300 calls and $150 of overspend on a $100 cap — a 150% overrun.
This is an illustrative property of that polling design, not a measured overrun or a claim about every provider limit.
An instrumented Cycles path instead reserves the submitted estimate before execution. The reservation mutation is atomic across the matching Cycles scopes; the caller still needs accurate estimates, consistent integration, and settlement of actual usage.
## When to use both
Provider caps and Cycles are not mutually exclusive. They serve as different layers of defense.
### Keep provider caps as a safety net
Provider account, credit, quota, and billing controls remain an independent line of defense. Which of them is a hard stop depends on the provider and configuration.
Configure those controls according to their documented semantics. Do not treat a soft budget alert as an absolute maximum.
### Use Cycles for operational control
Cycles can supply the operational budget decision for instrumented paths:
- Per-tenant limits that align with pricing tiers
- Per-workflow limits that prevent individual runs from spiraling
- Per-run limits that bound the cost of any single agent execution
- Configured caps that the application can map to degradation behavior
This layer supplies the budget decision; the application remains responsible for complete instrumentation, authorization, and fallback behavior.
### Defense in depth
The combination creates defense in depth:
1. **Cycles** handles configured budgets for instrumented operations with pre-execution checks.
2. **Provider controls** independently constrain or report the provider traffic they cover.
Multiple independent controls can catch different failure modes, but their exact guarantees come from their documented configuration—not from their position in this diagram.
## Migration path
Teams that currently rely on provider caps alone can adopt Cycles incrementally.
**Step 1: Shadow mode.** Deploy Cycles in shadow mode. It evaluates budget decisions but does not enforce or persist the dry-run result. Have the application log the result and compare it with what actually happened.
**Step 2: Validate.** Review the shadow mode data. Are the budget allocations correct? Are the scope hierarchies right? Would enforcement have blocked legitimate work? Adjust the configuration.
**Step 3: Enforce on new workflows.** Enable enforcement for new or low-risk workflows first. Keep shadow mode on everything else.
**Step 4: Expand enforcement.** Gradually move more workflows from shadow mode to enforcement as confidence builds.
**Step 5: Recheck provider controls.** Confirm that alerts, credits, quotas, and billing actions still match the organization's independent safety requirements.
The practical result is layered control: keep the provider-native protections that fit your account, and add application-scoped budgets where provider identities and windows do not express the boundary you need.
## Sources
Provider behavior was rechecked on July 24, 2026:
- [OpenAI spend limits](https://developers.openai.com/api/docs/guides/spend-limits)
- [OpenAI projects and limits](https://help.openai.com/en/articles/9186755-managing-projects-in-the-api-platform)
- [OpenAI prepaid billing](https://help.openai.com/en/articles/8264644-what-is-prepaid-billing)
- [Anthropic API billing and usage credits](https://support.anthropic.com/en/articles/8977456-how-do-i-pay-for-my-api-usage)
- [Anthropic cost and usage reporting](https://support.anthropic.com/en/articles/9534590-cost-and-usage-reporting-in-console)
- [Google Cloud generative AI throughput quota](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/resources/throughput-quota)
- [AWS Bedrock service quotas](https://docs.aws.amazon.com/bedrock/latest/userguide/quotas.html)
## Next steps
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Try the [End-to-End Tutorial](/quickstart/end-to-end-tutorial) — zero to a working budget-guarded LLM call in ten minutes
- [Multi-Tenant AI Cost Control](/blog/multi-tenant-ai-cost-control-per-tenant-budgets-quotas-isolation) — how provider and application scopes differ in shared systems
# Cycles vs Rate Limiting: Why Velocity Controls Fail for AI Agents
Rate limiting is one of the most widely deployed control patterns in software.
It works. It has worked for decades.
But it was designed for a different problem than the one AI agents create.
Rate limiters answer: **how fast?**
Cycles answers: **how much?**
That distinction determines whether your system can burn through $10,000 overnight while staying perfectly within its RPM limit.
> **Run the numbers for your workload:** [Cost Calculator →](/calculators/claude-vs-gpt-cost-standalone) — rate limits do not bound spend; the calculator shows what one un-budgeted runaway loop is worth at your token rate.
## What rate limiting does well
Rate limiters are effective at three things.
### Abuse prevention
A rate limiter keeps a bad actor from hammering your API. It sets a ceiling on request velocity per caller, per endpoint, or per time window. That is essential for any public-facing service.
### Traffic shaping
Rate limiters smooth bursty traffic. They protect downstream services from sudden spikes, keep queue depths manageable, and help maintain latency targets under load.
### Fairness
In multi-tenant systems, rate limiters ensure one tenant cannot monopolize shared resources. Every caller gets a fair share of throughput.
These are real, valuable properties. Nothing in this article suggests removing your rate limiter.
The question is whether rate limiting alone is sufficient when autonomous agents enter the picture.
It is not.
## Where rate limiting fails for AI agents
AI agents break the assumptions that make rate limiting sufficient.
### Rate limiters do not track cumulative cost
A rate limiter knows how many requests passed through in the last minute. It does not know how much those requests cost in total.
An agent that makes 10 requests per minute stays within a 60 RPM limit. But if each request triggers a long-context GPT-4 call with tool use, the cost per request might be $0.50 or more. That is $300 per hour. $7,200 per day. All within the rate limit.
The rate limiter sees normal traffic. The bill tells a different story.
### Rate limiters cannot distinguish cheap calls from expensive calls
To a rate limiter, every request is identical. A call that uses 100 input tokens and a call that uses 100,000 input tokens count the same: one request.
This is the fundamental mismatch. AI workloads have extreme cost variance between requests. A simple classification call might cost $0.001. A multi-turn agentic workflow with tool calls might cost $5.00. Both are one request.
Rate limiting treats them identically. Runtime authority cannot afford to.
### Rate limiters have no per-run or per-workflow awareness
A rate limiter operates at the connection level. It does not know that five requests belong to the same agent run, or that a workflow has fanned out into twelve parallel sub-tasks.
It cannot enforce: "this workflow may only spend $2 total." It can only enforce: "this caller may make N requests per time window."
That means an agent can spawn sub-tasks, retry failed steps, and loop through tool calls — all within the rate limit — while the total cost of a single run spirals.
### An agent can stay within RPM limits and burn $10K overnight
This is not a theoretical risk. It is the most common failure mode teams report.
The agent is well-behaved. It respects rate limits. It does not spike. It does not look like abuse.
It simply runs continuously, making steady, moderately expensive calls. Each call is allowed. The total is not governed.
By morning, the bill is $10,000. Nothing in the rate limiter flagged it.
The problem is not velocity. The problem is unbounded cumulative spend.
### No graceful degradation
When a rate limiter triggers, it returns 429 Too Many Requests. The client backs off and retries.
That is a binary response: allowed or throttled.
AI agents need a richer vocabulary. Sometimes the right answer is not "stop" but "continue with a cheaper model." Or "reduce the number of tool calls." Or "skip the optional enrichment step."
Rate limiters cannot express these nuances. They have one lever: velocity.
## Comparison
| | Rate Limiter | Cycles |
|---|---|---|
| **Controls** | Request velocity (RPM, RPS) | Total budgeted exposure (cost, tokens, units) |
| **Granularity** | Per-caller, per-endpoint, per-time-window | Per-tenant, per-workspace, per-workflow, per-agent |
| **Cost-aware** | No — every request counts equally | Yes — reserves estimated cost, commits actual cost |
| **Pre-execution budget check** | Velocity only — no cumulative awareness | Yes — checks remaining budget across all scopes before execution |
| **Concurrency-safe** | Yes for velocity counting | Yes — atomic reservations prevent race conditions on budget |
| **Degradation support** | No — binary allow/throttle | Yes — three-way decision: ALLOW, ALLOW_WITH_CAPS, DENY |
## How Cycles works where rate limiting cannot
Cycles introduces a reserve-then-commit model that is fundamentally different from velocity counting.
Before an agent action executes:
1. The system declares how much budget the action is expected to consume.
2. Cycles checks whether that budget is available across all applicable scopes (tenant, workspace, workflow, agent).
3. If available, the budget is atomically reserved. No other concurrent request can claim the same budget.
4. The action executes.
5. After execution, the system commits the actual cost. If the actual cost is less than the reservation, the remainder is released automatically.
This model answers questions that rate limiters cannot:
- Has this run already consumed too much? Then deny or degrade the next step.
- Is the tenant approaching its daily limit? Then switch to a cheaper model.
- Are concurrent requests about to exceed the workflow budget? The reservation is atomic — only one will succeed.
- Did the action cost less than expected? The unused budget is released for other work.
## The concurrency problem
Rate limiters handle concurrency well for velocity. They are designed for it.
But budget governance under concurrency is a different problem.
Consider two agent threads running in parallel against the same workflow budget. The budget has $5 remaining. Both threads check the budget, both see $5 available, and both proceed. Total spend: $10 against a $5 budget.
This is a classic time-of-check-to-time-of-use (TOCTOU) race condition. Rate limiters do not protect against it because they do not track cumulative spend.
Cycles handles this with atomic reservations. When the first thread reserves $5, that budget is immediately unavailable to the second thread. The second thread's reservation attempt sees the reduced balance and can be denied or degraded.
No oversubscription of the submitted estimates across matching Cycles ledgers. Actual provider spend still depends on estimate quality, complete instrumentation, and settlement policy.
## When to use both together
Rate limiting and Cycles solve different problems. Most production systems should use both.
**Keep your rate limiter for:**
- Abuse prevention — stopping bad actors from flooding your API
- Traffic shaping — smoothing bursts to protect downstream services
- Fairness — ensuring no single caller monopolizes throughput
- DDoS mitigation — absorbing malicious traffic spikes
**Add Cycles for:**
- Budget governance — bounding total spend per tenant, workflow, or run
- Cost-aware decisions — distinguishing cheap calls from expensive ones
- Graceful degradation — downgrading to cheaper models when budget is low
- Pre-execution enforcement — stopping expensive work before it starts
- Concurrency-safe accounting — preventing race conditions on budget
The two sit at different points in the request path.
A rate limiter typically sits at the edge — at the API gateway or load balancer. It decides whether the request may enter the system at all.
Cycles sits inside the application logic — at the point where an agent is about to make an expensive decision. It decides whether that specific action is allowed given the current budget state.
A request can pass the rate limiter (it is within velocity limits) and still be denied by Cycles (the budget is exhausted). These are independent, complementary checks.
## The architecture in practice
A typical flow looks like this:
```
Request arrives
→ Rate limiter: within RPM? → Yes → proceed
→ Cycles: budget available? → Reserve
→ Execute agent action (LLM call, tool use)
→ Cycles: commit actual cost, release remainder
```
If the rate limiter says no, the request never reaches Cycles. That is correct — abuse prevention should happen first.
If the rate limiter says yes but Cycles says no, the agent action does not execute. That is also correct — the work is within velocity limits but exceeds budget.
If both say yes, the action proceeds with reserved budget. After execution, the actual cost is committed and any unused reservation is released.
## The key insight
Rate limiting and runtime authority are orthogonal controls.
Rate limiting governs the speed of requests. It prevents bursts and abuse. It is stateless in the sense that it does not track what those requests cost in aggregate.
Runtime budget authority governs submitted cumulative exposure. It is stateful — it tracks reservations, commits, and remaining balances across configured scopes.
An AI agent that respects rate limits can still create unbounded cost.
For paths that must reserve before execution, Cycles prevents concurrent submitted estimates from oversubscribing the matching ledgers. Actual usage can exceed an estimate and is handled by the configured commit-overage policy; bypass traffic remains outside the boundary.
Rate limiting answers **how fast?**
Cycles answers **how much?**
Production systems need both answers.
## Next steps
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Try the [End-to-End Tutorial](/quickstart/end-to-end-tutorial) — zero to a working budget-guarded LLM call in ten minutes
- [The True Cost of Uncontrolled AI Agents](/blog/true-cost-of-uncontrolled-agents) — what happens when rate limits are the only line of defense
# Exposure: Why Rate Limits Leave Agents Unbounded
Exposure is the total cost, risk, or damage an autonomous system can create before something stops it.
It is not the same as spend. In an illustrative scenario, 200 mistaken customer emails cost about $1.40 in model tokens while their unquantified customer and business impact can be much larger. Low spend does not imply low exposure.
> **Quantify exposure for your agent:** [Blast Radius Risk Calculator →](/calculators/ai-agent-blast-radius-standalone) — model action classes by reversibility and visibility; the catastrophic *irreversible + public* class is what rate limits leave unbounded.
## Why exposure matters
Every autonomous system has two numbers:
1. **Spend** — what it costs to run (tokens, compute, API fees)
2. **Exposure** — what it can do before it is stopped (emails sent, records modified, deploys triggered, dollars committed)
Most cost controls target one dimension. Rate limits cap throughput. Provider controls may alert on budgets, consume prepaid credits, or enforce account and capacity limits. Observability dashboards report what happened. None automatically represents every application run, tenant, or side effect.
None of these bound exposure, because none of them enforce limits **before** the next action executes.
## Rate limits don't help
A rate limit of 100 requests per minute does not prevent an agent from sending 100 emails in that minute. It controls velocity, not authorization. The agent is never asked "should this action proceed?" — it is only told "slow down."
Rate limits are designed for shared infrastructure protection. They are not designed for autonomous agent governance. See [Why Rate Limits Are Not Enough](/concepts/why-rate-limits-are-not-enough-for-autonomous-systems).
## Observability doesn't help
Dashboards and tracing systems record what happened. They can alert after the fact. But by the time a human sees the alert, the agent has already acted. In the email scenario, the 200 messages are sent. In a runaway loop, the budget is already burned.
Observability is essential — but it observes exposure. It does not bound it. See [From Observability to Enforcement](/concepts/from-observability-to-enforcement-how-teams-evolve-from-dashboards-to-budget-authority).
## How reserve-commit bounds exposure
Cycles bounds exposure by requiring agents to **reserve** budget before execution and **commit** the actual cost afterward.
The reservation is the enforcement point. If sufficient budget is unavailable, a live reservation fails and correctly integrated protected work does not execute. This bounds the submitted cumulative exposure at that boundary; it does not cap all possible business harm, validate the estimate, or cover work that bypasses instrumentation.
This applies to both financial exposure (USD, tokens) and operational exposure (risk points). A toolset-scoped budget denominated in RISK_POINTS can cap the number of consequential actions an agent takes, regardless of their dollar cost. See [Action Authority](/concepts/action-authority-controlling-what-agents-do).
For practical strategies on sizing reservations and estimating exposure before execution, see [Exposure Estimation](/how-to/how-to-estimate-exposure-before-execution-practical-reservation-strategies-for-cycles).
## Next steps
- [Glossary: Exposure](/glossary#exposure) — formal definition
- [Runaway Agents and Tool Loops](/incidents/runaway-agents-tool-loops-and-budget-overruns-the-incidents-cycles-is-designed-to-prevent) — what unbounded exposure looks like in practice
- [Demos](/demos/) — the runaway agent demo shows a cost runaway stopped at $1.00 by reserve-commit
# From Observability to Enforcement: How Teams Evolve from Dashboards to Runtime Authority
Most teams do not begin with enforcement.
They begin with visibility.
They add logs.
They add traces.
They monitor provider usage.
They build dashboards.
They set alerts for abnormal spend.
They review incidents after they happen.
That is the right starting point.
But as autonomous systems become more capable, visibility alone stops being enough.
> **Quantify the gap dashboards leave open:** [Cost Calculator →](/calculators/claude-vs-gpt-cost-standalone) · [Blast Radius Risk Calculator →](/calculators/ai-agent-blast-radius-standalone) — observability records what happened; the calculators show the budget envelope and the risk envelope that no alert closes.
At some point, the question changes from:
::: info
What happened?
:::
to:
::: info
What should be allowed to happen next?
:::
That is the transition from observability to enforcement.
It is also the transition that Cycles is designed to support.
## Why observability comes first
Observability is usually the first control layer because it is easy to adopt and low risk.
It does not block execution.
It does not change application behavior.
It does not require the team to make hard policy decisions immediately.
It helps answer questions like:
- which workflows are expensive?
- which tenants consume the most?
- where do retries happen?
- which tools are called most often?
- which runs are unusually long?
- how does actual usage vary over time?
These are necessary questions.
A team cannot govern what it cannot see.
That is why most systems start here.
## Why observability eventually stops being enough
The problem is that observability is passive.
It can explain what happened.
It cannot, by itself, stop the next incident.
A dashboard can tell you a workflow burned through budget.
An alert can tell you a tenant exceeded expected usage.
A trace can show you that a tool loop retried six times.
But all of that happens after the relevant work already executed.
That matters less in traditional software where failures are often discrete and bounded.
It matters much more in autonomous systems, where cost and side effects accumulate over time.
The more a system can:
- loop
- retry
- fan out
- recurse
- continue in the background
- trigger side effects
the less sufficient post-hoc visibility becomes.
## The maturity curve
Most teams move through a recognizable sequence.
### Stage 1: Basic usage visibility
At this stage, teams can answer:
- how much did we spend?
- which provider was used?
- which tenant generated the most traffic?
This is useful, but still coarse.
### Stage 2: Workflow-level observability
The team begins to understand:
- which workflows are expensive
- how usage distributes across runs
- where retries cluster
- which tools amplify cost
- which execution paths are noisy
This is much better.
The team now has operational visibility into autonomous behavior, not just aggregate billing.
### Stage 3: Alerting and anomaly detection
Next, the team starts reacting to:
- usage spikes
- tenant anomalies
- unexpectedly long runs
- retry storms
- sudden workflow fan-out
This creates faster feedback, but still does not introduce bounded control.
### Stage 4: Soft controls and heuristics
Teams often add:
- ad hoc loop counters
- static max-step thresholds
- timeout tuning
- hardcoded fallbacks
- kill switches
- per-feature caps
These controls can help, but they are often fragmented and inconsistent.
### Stage 5: Runtime authority
Eventually the team realizes it needs one thing the earlier stages do not provide:
**a runtime decision point before autonomous work proceeds**
That is where enforcement begins.
## What changes at the enforcement stage
Enforcement adds a new question:
::: info
Is this action still authorized to continue under the current budget?
:::
That is different from asking how much the system spent yesterday or which run was expensive.
It means the system now needs to make bounded decisions in real time.
That includes questions like:
- may this model call proceed?
- should this tool invocation still be allowed?
- is this run already too expensive?
- should the workflow degrade instead of continuing normally?
- has this tenant exhausted its budget envelope?
- should a background job stop here?
This is the move from descriptive operations to governing execution.
## Why dashboards are necessary but insufficient
A useful way to think about it is:
- **dashboards explain**
- **runtime authority decides**
You still want the dashboard.
You still want traces and alerts.
But once systems become autonomous, decision-making needs a control surface too.
Otherwise teams end up in a loop of:
1. observe incident
2. write another heuristic
3. observe a new variant
4. add another exception
5. repeat
That tends to produce fragile policy and unclear ownership.
## The missing primitive
The missing primitive is not “more analytics.”
It is a way for the runtime to ask for bounded room to act before work proceeds.
That is what Cycles introduces.
At a high level:
1. declare intended exposure
2. reserve budget
3. execute
4. commit actual usage or release the remainder
This turns enforcement into a lifecycle rather than a spreadsheet.
Instead of only knowing what happened later, the system can decide whether work is allowed to continue now.
## Why this transition matters more for autonomous systems
Autonomous systems create a different operational shape than traditional request-response applications.
A single initiating event may lead to:
- many model calls
- repeated retries
- multiple tool invocations
- workflow branching
- asynchronous continuation
- external side effects
That means “request count” and “API throughput” stop being good proxies for real operational exposure.
The system needs to reason about total bounded execution, not just traffic volume.
This is why observability naturally leads to runtime authority as systems mature.
## A common evolution pattern
A typical team often evolves like this.
### Early phase
The team is mostly trying to understand behavior.
It wants visibility, not restrictions.
Questions sound like:
- what is this costing?
- where are tokens going?
- which paths are expensive?
### Middle phase
The team starts seeing incidents or near misses.
Questions become:
- why did this run keep going?
- why did retries multiply spend?
- why did one tenant consume so much?
- why did this workflow call tools so many times?
At this point, observability exposes the problem clearly.
### Later phase
The team realizes that understanding is not the same as control.
Questions become:
- how do we stop this next time?
- how do we prevent it before cost lands?
- how do we add hard boundaries without breaking everything?
- how do we make policy hold under retries and concurrency?
That is when runtime authority becomes necessary.
## What enforcement should not mean
Enforcement does not have to mean “deny everything aggressively.”
A mature control model often includes multiple outcomes:
- allow normally
- return a hypothetical dry-run decision for the application to log
- degrade to a smaller model
- disable costly tools
- switch to read-only behavior
- reduce concurrency
- deny further execution
This is important because the move from observability to enforcement is not a move from flexibility to rigidity.
It is a move from passive awareness to intentional control.
## Why teams get stuck before enforcement
Many teams understand the value of bounded execution but still hesitate to adopt it.
Common reasons include:
- uncertainty about the right thresholds
- fear of breaking production
- incomplete understanding of workflow usage
- lack of estimate quality
- worry about false denials
- fragmented ownership between platform and application teams
These are real concerns.
That is why Cycles includes shadow-mode-friendly thinking as part of the model.
Teams often need to observe policy against real workloads before turning on hard stops.
## The role of shadow mode in the maturity curve
Shadow mode is often the bridge between observability and enforcement.
With `dry_run: true`, Cycles returns a hypothetical decision without creating a reservation or modifying balances. The application must retain that response and correlate it with actual usage. Those records let teams ask:
- what would have been denied?
- which runs would have exceeded budget?
- which tenants are routinely near limits?
- how well do estimates match actuals?
- what should degrade instead of fail?
That means the maturity curve is usually not:
**observe → enforce**
It is more often:
**observe → evaluate in shadow → enforce intentionally**
That is a much safer operational path.
## What changes once runtime authority exists
Once a system has a real runtime authority, several things change.
### Instrumented incidents become easier to bound
Instead of relying only on dashboards and operator reaction, a mandatory application boundary can stop or degrade protected work when a budget request fails.
### Policy becomes explicit
Instead of scattered budget heuristics across code, the platform gains a clearer model of submitted exposure at tenant and workflow levels. An application can use a run ID as `subjects.workflow` when it needs one ledger per execution; a run ID in `dimensions` is attribution only.
### Teams can reason about autonomy operationally
The conversation changes from:
- “why did this get expensive?”
to:
- “what execution envelope should this class of work have?”
That is a more mature operational question.
### Platform and product alignment improves
Budgets become a shared boundary between:
- platform economics
- product behavior
- execution safety
That is healthier than leaving those concerns disconnected.
## A concrete example
Imagine a support automation platform.
At first, the team only tracks:
- provider cost
- request count
- tenant usage totals
Then it starts seeing specific incidents:
- some runs call models repeatedly
- some workflows retry several times
- some tenants concentrate usage in a few expensive flows
The team adds dashboards and alerts. That helps explain the incidents.
But then one run again consumes far too much budget overnight.
At that point, the missing piece is clear.
The platform does not need another chart.
It needs a way to say:
- protected actions in this run share a workflow ledger with this allocation
- this tenant has this much remaining
- this application should degrade the workflow when a reservation is denied or returned caps require it
- this next action may not proceed unless budget is reserved first
That is the shift from observability to enforcement.
## Why Cycles fits this transition
Cycles is designed for teams that have already realized visibility alone is not enough.
It provides a runtime model for:
- pre-execution budget checks
- reserve → commit / release lifecycle handling
- hierarchical scope enforcement
- retry-safe accounting
- shadow evaluation before hard enforcement
In other words, it helps teams operationalize what observability has already taught them.
## Summary
Observability is where autonomous control begins.
It helps teams understand:
- what systems are doing
- where cost accumulates
- which workflows are unstable
- where retries and fan-out create pressure
But understanding alone does not prevent the next incident.
As systems become more autonomous, teams need a control layer that can decide whether work should continue before cost and side effects grow further.
That is the move from dashboards to runtime authority.
That is the move Cycles is built to support.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
- [AI Agent Cost Management: The Complete Guide](/blog/ai-agent-cost-management-guide) — the five-tier maturity model for moving from no controls to hard enforcement
- [Cycles vs LLM Proxies and Observability Tools](/blog/cycles-vs-llm-proxies-and-observability-tools) — where budget enforcement fits alongside LiteLLM, Portkey, Helicone, and Langfuse
# How Cycles Compares to Rate Limiters, Observability, Provider Caps, In-App Counters, and Job Schedulers
## Quick comparison
| Approach | What it controls | Pre-execution? | Per-tenant? | Cost-aware? | Degradation? |
|---|---|:---:|:---:|:---:|:---:|
| **Rate limiter** | Request velocity | Velocity only | Partial | No | No |
| **Observability** | Traces, metrics, and product-specific controls | Product/config dependent | Product/config dependent | Usually | Product/config dependent |
| **Provider controls** | Vendor spend, credits, or capacity | Soft or hard, vendor dependent | Provider identity only | Yes for covered usage | Application chooses fallback |
| **In-app counter** | Custom metric | Partial | Partial | Partial | No |
| **Job scheduler** | Execution timing | No | No | No | No |
| **Cycles** | Bounded budget, risk exposure | Yes | Yes | Yes | Yes (ALLOW / ALLOW_WITH_CAPS / DENY) |
---
Teams building autonomous systems usually already have some controls in place.
- Rate limiters.
- Observability platforms.
- Provider budget caps.
- In-app usage counters.
- Job schedulers with retry logic.
These are all reasonable tools. They each solve real problems.
But none of them solve the problem Cycles is designed for: **governing bounded execution before autonomous work proceeds**.
This article walks through each alternative, explains what it does well, where it falls short, and how Cycles differs. For the short-form essay version of the same argument — focused on *why* tool-local controls cannot stretch to cover an agent that spans providers, tools, tenants, and workers — see [Agents Are Cross-Cutting. Your Controls Aren't.](/blog/agents-are-cross-cutting-your-controls-arent).
## Rate limiting vs Cycles
Rate limiters control **velocity**.
They answer: how many requests per second, minute, or hour may this caller make?
That is useful for:
- abuse prevention
- traffic shaping
- fairness across tenants
- protecting downstream systems from bursts
### Where rate limiting falls short
Rate limiters do not track total consumption.
An agent can stay within its request-per-second limit and still burn through an entire budget over hours.
Nothing spikes.
Nothing looks like abuse.
The system is simply allowed to continue indefinitely.
Rate limiters also do not understand execution context:
- they do not know this is the third retry of the same action
- they do not know the run is already 80% over budget
- they do not know the workflow has fanned out into expensive sub-tasks
- they do not distinguish between a $0.001 call and a $2.00 call
Every request looks the same to a rate limiter.
### How Cycles differs
Cycles controls **total bounded exposure**, not request velocity.
Before work begins, the system reserves budget.
After work completes, it commits actual usage.
Unused budget is released.
That means a run cannot quietly accumulate cost beyond its envelope, regardless of how slowly or quickly it acts.
| | Rate limiter | Cycles |
|---|---|---|
| Controls | Requests per time window | Total budgeted exposure |
| Granularity | Per-caller or per-endpoint | Per-tenant, workspace, app, workflow, agent, toolset |
| Understands retries | No | Yes (idempotent reservations) |
| Understands cost | No | Yes (reserve estimated, commit actual) |
| Pre-execution check | Velocity only | Budget availability across scopes |
| Lifecycle | Stateless counter | Reserve → execute → commit/release |
**Keep your rate limiter.**
It protects against bursts and abuse.
But do not expect it to govern what an autonomous system is allowed to consume in total.
## Observability vs Cycles
Observability answers: **what happened?**
It helps teams understand cost, behavior, and anomalies after execution occurs.
Good observability includes:
- usage dashboards
- per-tenant cost breakdowns
- workflow traces
- retry and error distributions
- spend-over-time charts
- anomaly alerts
### Where observability falls short
Observability is passive.
A dashboard can show that a runaway workflow consumed $400 overnight.
An alert can tell you a tenant exceeded expected usage.
A trace can reveal a tool loop that retried twelve times.
All of that is valuable.
None of it prevented the incident.
Post-hoc visibility helps teams improve.
It does not help the runtime decide whether the next action should proceed.
The gap is especially visible in autonomous systems where:
- loops can run for hours without triggering alerts
- cost accumulates gradually, not in spikes
- the damage is done by the time the alert fires
- response requires human intervention, which may not come fast enough
### How Cycles differs
Cycles introduces a **pre-execution decision point**.
Instead of only explaining what happened afterward, Cycles determines whether work is allowed to continue now.
The system asks: is there enough budget remaining for this action?
If yes, budget is reserved and work proceeds.
If no, the system can DENY, degrade (ALLOW_WITH_CAPS), or defer the action. When degrading, Cycles returns cap fields — `max_tokens`, `max_steps_remaining`, `tool_allowlist`, `tool_denylist`, and `cooldown_ms` — so the caller knows exactly how to constrain the next action.
| | Observability | Cycles |
|---|---|---|
| Timing | After execution | Before and during execution |
| Purpose | Explain what happened | Decide what may happen |
| Response to overruns | Alert, investigate, fix later | Deny, degrade, or defer in real time |
| Requires human response | Often yes | No (automated enforcement) |
| Lifecycle awareness | Traces and logs | Reserve → commit/release |
**Keep your observability stack.**
Cycles benefits from good observability. It does not replace it.
But do not confuse explaining the past with governing the present.
## Provider budget caps vs Cycles
Most LLM providers offer some form of spending cap or usage limit.
Depending on the vendor and plan, these include:
- monthly spend alerts or hard limits on an organization or project
- prepaid-credit cutoffs
- daily or monthly spend alerts
- per-model request or token-rate limits
### Where provider caps fall short
Provider controls are scoped to vendor-defined identities and are external to your application's own subject hierarchy.
They operate at the wrong level of granularity for autonomous systems.
**No automatic application-tenant enforcement.**
A provider project, workspace, account, or key can isolate traffic assigned to it. When multiple application tenants share that identity, however, the provider cannot infer their separate ledgers. One tenant can consume the shared allowance.
**No automatic per-run or per-workflow limit.**
A team could dedicate a provider project or key to a workload where supported, but a shared provider boundary does not learn the application's workflow or run identity.
**Provider-specific enforcement.**
Some controls only alert; others reject affected requests when a credit, quota, or hard spend limit binds. The provider does not know which application-specific degradation path is safe, so the application must choose the fallback.
**No application reserve-commit semantics.**
Provider controls can reject at their request boundary, but they do not expose a reserve-commit lifecycle for an application-defined tenant/run estimate. For example, OpenAI documents that hard spend-limit enforcement is not instantaneous and tracked spend can slightly exceed the configured amount.
**Timing varies.**
Alerts, dashboards, hard spend limits, credit checks, and rate quotas have different timing. Treat each according to its documented behavior rather than assuming every control is either immediate or delayed.
**No lifecycle awareness.**
Provider controls do not infer that a request belongs to an application retry, how much a particular run has used, or which workflow-specific degradation is safe.
### How Cycles differs
Cycles provides budget state for caller-supplied application scopes.
It operates at the level your system actually needs:
- per-tenant
- per-workspace
- per-app
- per-workflow
- per-agent
- per-toolset
Budget is reserved before execution rather than inferred after usage occurs.
| | Provider controls | Cycles |
|---|---|---|
| Scope | Vendor organization, project, workspace, account, key, credit, or quota | Caller-supplied tenant, workspace, app, workflow, agent, toolset |
| Enforcement | Soft or hard, vendor/configuration dependent | Live reservation accepted with optional configured caps, or rejected |
| Timing | Alert, request-time rejection, or non-instantaneous spend cutoff | Pre-execution hold for instrumented work |
| Multi-tenant aware | Only through explicit provider-identity mapping | Yes, when the caller submits tenant scopes |
| Degradation support | Provider-specific; application chooses fallback | Configured caps returned; application applies fallback |
| Retry-safe | Provider-specific | Same idempotency key and body deduplicate a budget mutation |
| Under your control | No (vendor-managed) | Yes (self-hosted, operator-defined) |
**Provider controls are an independent safety layer.**
Depending on their semantics, they can alert, constrain capacity, exhaust credits, or stop provider traffic at an organization or project boundary.
But they are not a substitute for application-level budget governance.
## In-app counters vs Cycles
Many teams build their own usage counters.
These are typically:
- a database column tracking tokens used per tenant
- an in-memory counter incremented after each model call
- a Redis key tracking spend per run
- a custom middleware that checks a threshold before calling the model
This is often the first thing teams build when they realize they need per-tenant or per-run limits.
### Where in-app counters fall short
In-app counters work in simple cases. They break down as systems become more complex.
**Race conditions under concurrency.**
If two requests check the counter simultaneously, both may see "under budget" and proceed. The result is overspend. Solving this correctly requires atomic operations, locks, or compare-and-swap — which most ad hoc counters do not implement.
**No reservation semantics.**
Counters typically increment after execution. That means the system commits to work before knowing whether the budget can absorb it. If the model call costs more than expected, the counter reflects reality too late.
**No hierarchical scopes.**
A counter per tenant is useful. But autonomous systems often need limits at multiple levels such as tenant, workspace, app, workflow, agent, and toolset. An application can key a workflow ledger per run, while action authorization remains a host concern. Building and maintaining hierarchical counters with correct rollup logic is significantly more complex than a single counter.
**Fragile under retries.**
If a request fails and retries, does the counter increment once or twice? If the retry uses a different code path, does it check the same counter? Most ad hoc counters do not handle retries cleanly.
**No lifecycle management.**
Counters have no concept of reservations, releases, TTLs, or grace periods. Budget is either "used" or "not used." There is no way to reserve estimated exposure before execution, commit actuals afterward, or release unused budget.
**Scattered implementation.**
Counter logic often lives inside business code, spread across services and endpoints. It is hard to audit, hard to test, and hard to make consistent.
### How Cycles differs
Cycles replaces ad hoc counters with a purpose-built runtime authority.
It handles concurrency, retries, hierarchical scopes, and lifecycle semantics as first-class concerns — not afterthoughts.
| | In-app counter | Cycles |
|---|---|---|
| Concurrency safety | Usually racy | Atomic reservations |
| Timing | Post-execution increment | Pre-execution reservation |
| Hierarchical scopes | Rarely | Built-in (tenant → workspace → app → workflow → agent → toolset) |
| Retry handling | Fragile | Idempotent lifecycle |
| Lifecycle support | None | Reserve → commit / release / extend |
| TTL and expiry | Manual if at all | Built-in reservation TTL and grace |
| Audit and consistency | Scattered | Centralized authority |
**In-app counters are a natural starting point.**
But they tend to accumulate correctness bugs and edge cases as the system scales.
Cycles is what teams adopt when ad hoc counters stop being reliable under real concurrency, retries, and fan-out.
## Job schedulers and retry logic vs Cycles
Job schedulers manage **when and how work executes**.
They handle:
- task queuing
- retry policies (backoff, max attempts)
- cron-based scheduling
- dead-letter queues
- concurrency limits on workers
- task deduplication
Common examples include Celery, Sidekiq, Bull, Temporal, Spring Batch, and Quartz.
### Where job schedulers fall short
Job schedulers govern execution mechanics.
They do not govern execution economics.
**Retry policies do not understand budget.**
A scheduler may retry a failed task five times. Each retry may call an LLM. The scheduler does not know or care that the run has already exhausted its budget. It only knows that the retry count has not been reached.
**Concurrency limits are not budget limits.**
A scheduler may allow ten concurrent workers. That limits parallelism, not total cost. Ten workers can each burn through expensive model calls simultaneously without any aggregate budget check.
**No cost awareness.**
A scheduler does not know that one task costs $0.01 and another costs $5.00. It treats all tasks equally. It cannot route a task to a cheaper path when budget is low, or deny an expensive task when the run is nearly exhausted.
**No cross-run or cross-tenant visibility.**
A scheduler manages individual jobs. It does not maintain a budget ledger across tenants, workflows, or time windows. It cannot answer: "has this tenant already consumed too much today?"
**Scheduling is orthogonal to governance.**
A scheduler decides: should this task run now, later, or again?
It does not decide: is this task allowed to run given the current budget state?
### How Cycles differs
Cycles provides budget governance that is orthogonal to — and complementary with — job scheduling.
A scheduler can call Cycles before executing a task.
Cycles can tell the scheduler whether the task should proceed, degrade, or be denied.
| | Job scheduler | Cycles |
|---|---|---|
| Controls | When and how work runs | Whether work is allowed given budget |
| Retry awareness | Max attempts, backoff | Budget remaining across retries |
| Cost awareness | None | Reserve estimated, commit actual |
| Scope | Per-job or per-queue | Per-tenant, workspace, app, workflow, agent, toolset |
| Degradation | Not built-in | Three-way (ALLOW / ALLOW_WITH_CAPS / DENY) |
| Cross-tenant limits | No | Yes |
| Complements | Execution engine | Runtime authority |
**Keep your scheduler.**
It is the right tool for managing execution timing and retry mechanics.
But pair it with a runtime authority so retries and fan-out do not become unbounded cost.
## Capability matrix
The table below maps specific capabilities against each approach.
| Capability | Rate limiter | Observability | Provider cap | In-app counter | Job scheduler | Cycles |
|---|---|---|---|---|---|---|
| Pre-execution budget check | No | No for tracing-only tools | Yes, at provider/gateway scope | Partial | No | Yes |
| Caller-estimated reserve-commit lifecycle | No | No | No | Partial, if custom-built | No | Yes |
| Per-tenant limits | Partial | Attribution, not enforcement | Partial, product-specific | Partial | No | Yes |
| Per-workflow / per-agent limits | Partial, with custom keys | Attribution, not enforcement | Partial, product-specific | Partial | Partial | Yes |
| Hierarchical scopes | No | Partial for grouping | Partial, product-specific | Partial | Partial | Yes |
| Cost-aware decisions | No | No for tracing-only tools | Yes | Partial | No | Yes |
| Retry / idempotency safety | Partial | No | Partial | Partial | Yes for job execution | Yes for budget lifecycle |
| Configured `ALLOW_WITH_CAPS` outcome | No | No | Product-specific alternatives | Partial | No | Yes; caller applies caps |
| Concurrency-safe accounting | No | No | Product-specific | Partial | Partial | Yes, for reservations |
| Real-time enforcement | Yes, for traffic | No for tracing-only tools | Yes, at provider/gateway scope | Partial | No | Yes, at instrumented boundary |
| Post-hoc analysis and traces | No | Yes | Partial | Partial | Partial | Partial; lifecycle records only |
| Traffic shaping / abuse prevention | Yes | No | Partial | No | Partial | No |
| Execution scheduling and retries | No | No | No | No | Yes | No |
A few things worth noting:
- No single tool covers every row. That is expected.
- "Partial" covers product-specific support or substantial custom work. Check the exact gateway, provider, or scheduler rather than treating a category as one uniform product.
- Cycles does not try to replace traffic shaping, observability, or scheduling. Those are separate concerns with mature tooling. Cycles focuses on the budget governance column because that is the gap most teams hit as autonomous systems scale.
Each of these tools earns its place in a production stack. The question is whether the stack has a gap where runtime authority should be.
## They work together
Cycles does not replace any of these tools.
It fills the gap between them.
A well-governed autonomous system typically includes:
- a **rate limiter** for traffic shaping and abuse prevention
- an **observability platform** for visibility, traces, and alerts
- **provider controls** as an independent vendor-side boundary
- a **job scheduler** for execution timing and retry policies
- **Cycles** as the budget authority for caller-submitted application scopes
These layers are complementary, not competitive.
The question is not "which one should I use?"
It is "which layer is missing?"
For most teams building autonomous systems, the missing layer is runtime authority.
## When to adopt Cycles
Consider adding Cycles to your stack when:
- **Agents run autonomously** — without human-in-the-loop approval for each action
- **Cost is unpredictable** — fan-out, tool loops, or retries make per-run cost hard to bound
- **Multiple tenants share infrastructure** — one tenant's runaway agent should not affect others
- **You need graceful degradation** — switching to cheaper models or reducing scope when budget is low, rather than hard-failing
- **Governance requires budget evidence** — lifecycle records showing which instrumented operations reserved, committed, released, or were denied against configured budgets
- **You've outgrown ad hoc counters** — custom counters work until concurrency, retries, and hierarchy make them unreliable
If none of these apply yet, start with [shadow mode](/how-to/shadow-mode-in-cycles-how-to-roll-out-budget-enforcement-without-breaking-production) to see what enforcement would look like on your current traffic.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
- Browse the full [Integration Ecosystem](/how-to/ecosystem)
- [At-a-Glance Comparisons](/concepts/comparisons) — quick reference table comparing Cycles to seven alternatives
- [5 Real-World AI Agent Failures That Budget Controls Would Have Prevented](/blog/ai-agent-failures-budget-controls-prevent) — concrete failure scenarios and what each approach prevents
- [Cycles vs LLM Proxies and Observability Tools](/blog/cycles-vs-llm-proxies-and-observability-tools) — how Cycles complements LiteLLM, Portkey, Helicone, and Langfuse
- [Budget Wrapper vs Budget Authority](/blog/vibe-coding-budget-wrapper-vs-budget-authority) — why wrapping LLM calls is not the same as governing them
- [AI Agent Cost Management Guide](/blog/ai-agent-cost-management-guide) — comprehensive guide to managing AI agent costs at scale
# Idempotency, Retries, and Concurrency: Why Cycles Is Built for Real Failure Modes
Most budget systems look correct in the happy path.
A request arrives.
The system checks a counter.
Work executes.
Usage is recorded.
Everything looks fine.
Real systems do not behave that cleanly.
They retry.
They time out.
They crash halfway through execution.
They send duplicate requests.
They run multiple workers at once.
They fan out across steps that all consume budget concurrently.
That is where naive accounting breaks down.
Cycles is built for these conditions on purpose.
It is not only a budgeting model.
It is a runtime control model designed for failure, duplication, and concurrent execution.
## Why happy-path accounting is not enough
A simple usage counter can tell you how much was spent after work is done.
That may be enough for reporting.
It is usually not enough for safe enforcement.
Consider a few common failure cases:
- a client retries because it did not receive a response
- a worker crashes after reservation but before reconciliation
- two workers both try to reserve against the same remaining budget
- a duplicate message is processed twice
- a workflow branch commits usage after a parent path already retried
- actual usage arrives late, out of order, or more than once
If the accounting model is not designed for these cases, the system tends to produce one of three outcomes:
- accidental double-spend
- false denials caused by leaked reservations
- inconsistent enforcement under concurrency
These are not edge cases in autonomous systems.
They are normal operating conditions.
## The problem with naive budget checks
A naive budget check often looks like this:
1. read current balance
2. compare against requested amount
3. if enough remains, proceed
4. update the balance later
That seems reasonable until two things happen at once.
For example:
- worker A reads available budget = 100
- worker B reads available budget = 100
- both decide to proceed
- both consume 80
Now the system has allowed 160 units of work against 100 units of available budget.
This is the classic race condition that appears whenever control decisions are separated from atomic state changes.
Cycles exists to avoid this category of failure.
## Why idempotency matters
Idempotency means the same logical action can be retried safely without being counted multiple times.
This is essential because retries happen for many reasons:
- the client timed out waiting for a response
- the network dropped after the server processed the request
- a worker crashed after partially completing work
- a message broker redelivered the same event
- an upstream service retried defensively
Without idempotency, every retry looks like a new request.
That can create:
- duplicate reservations
- duplicate commits
- duplicate releases
- budget drift
- over-counting that has nothing to do with real usage
In a production control plane, retry safety is not optional.
It is part of correctness.
## Why reservation alone is not enough
Some systems try to solve budgeting with simple pre-checks or flat quota decrements.
That helps, but it is still incomplete.
Cycles uses a lifecycle:
1. reserve
2. execute
3. commit actual usage or release the remainder
Each part exists because execution is messy.
### Reserve
Reserve creates bounded room to act before work begins.
### Commit
Commit reconciles estimated usage with actual usage after work completes.
### Release
Release returns any unused reservation when work exits early, is canceled, or consumes less than expected.
Without all three, real failure handling becomes unreliable.
## Retries create two different kinds of problems
Retries are often discussed as one thing, but they actually create two different accounting problems.
### 1. Duplicate intent
The same logical operation may be submitted more than once.
Example:
- the client sends a reservation request
- the server processes it
- the response is lost
- the client retries
If the second request is treated as new, the system may reserve twice.
### 2. Duplicate completion
The same execution may attempt to commit or release more than once.
Example:
- a worker completes a task
- commit is sent
- timeout occurs before acknowledgment
- the worker retries the commit
If commit is not idempotent, the system may count actual usage multiple times.
Both problems are common.
Both must be handled explicitly.
## Why concurrency changes everything
Concurrency is where many “good enough” budget systems fail.
A single-threaded demo can make almost anything look correct.
Production systems are different.
Multiple requests may:
- reserve simultaneously
- commit simultaneously
- release simultaneously
- affect shared parent scopes
- race at both local and ancestor levels
This becomes even more complex in hierarchical models where one action may consume budget from several scopes at once, such as:
- tenant
- workflow
- run
If these mutations are not handled carefully, concurrency breaks the guarantee that budgets are meant to provide.
That is why Cycles is built around deterministic reservation semantics rather than loose after-the-fact reconciliation.
## Hierarchical budgets make naive logic even less safe
Flat counters are already easy to get wrong.
Hierarchical governance makes the problem more important.
Suppose an action must be valid against:
- tenant budget
- workflow budget
- run budget
A naive system might check these one at a time without a coherent control model.
That can create partial success conditions such as:
- local scope looks valid
- ancestor scope is exhausted
- a concurrent request changes shared state between checks
- a retry replays part of the sequence
Now the system has to answer difficult questions:
- was the action really allowed?
- what should be rolled back?
- which scopes were partially consumed?
- did duplicate handling happen consistently?
This is why budget control in autonomous systems cannot be reduced to “just keep a counter.”
## What Cycles is designed to protect against
Cycles is built for conditions like:
- duplicate reservation attempts
- duplicate commit attempts
- duplicate release attempts
- worker crashes after reserve
- worker crashes after partial execution
- network retries
- concurrent reservation pressure
- hierarchical scope contention
- partial completion with leftover reserved budget
These are the conditions that make simple usage tracking insufficient.
They are also the conditions that determine whether a control layer can be trusted in production.
## Why commit and release must be explicit lifecycle events
A common mistake is to assume that if work starts and finishes normally, accounting is easy.
But real systems often produce incomplete execution paths.
For example:
- work reserves budget but exits before making the expensive call
- work consumes only part of the reserved amount
- work completes but the accounting acknowledgment is delayed
- work is retried by a second worker while the first result is uncertain
If commit and release are not first-class lifecycle events, the system has no clean way to reconcile what actually happened.
That creates either leakage or double counting.
Explicit lifecycle events make these transitions governable.
## Why observability alone is not enough
Some teams try to solve these issues with logging, traces, dashboards, and periodic reconciliation.
Those are valuable tools.
They are not the same as runtime correctness.
Observability can tell you:
- a duplicate happened
- a retry occurred
- usage drift appeared
- a workflow behaved oddly
But it cannot prevent the initial overage or race by itself.
A runtime authority must do more than explain failure after the fact.
It must remain correct enough under failure to make enforcement meaningful.
## A concrete example
Imagine a workflow step that estimates it needs 100 units.
The system reserves 100 and begins execution.
Then:
- the worker calls a model
- the model call succeeds
- the worker crashes before commit
- the job is retried on another worker
Now the platform must reason about several things:
- was the original reservation already created?
- should the retry create another reservation?
- did actual usage already happen once?
- if the retry commits, is that a duplicate or new consumption?
- if the original reservation is still outstanding, when is the remainder released?
This is not a rare corner case.
This is exactly the kind of ambiguity production systems create.
A runtime model that ignores these realities becomes financially noisy and operationally untrustworthy.
## Cycles is about bounded execution under uncertainty
One of the key ideas behind Cycles is that enforcement has to survive imperfect information.
At the moment a decision is made, the system may not yet know:
- whether a prior request will be retried
- whether a worker will crash
- whether actual usage will equal the estimate
- whether another concurrent path is about to consume shared budget
That is why Cycles does not rely on a single final usage event.
It uses a lifecycle that can tolerate uncertainty more gracefully:
- reserve bounded room first
- execute work
- reconcile actuals later
- return unused remainder
- remain safe under duplicate and concurrent behavior
## What “real failure modes” means in practice
When we say Cycles is built for real failure modes, we mean it is designed for environments where the following are normal:
- retries are expected
- duplicate delivery happens
- workers fail mid-flight
- state transitions are not perfectly synchronized
- multiple actors compete for shared budget
- long-running workflows can outlive the request that started them
This is the world of production autonomous systems.
A budget system that assumes clean sequential execution may work in a demo and fail in the exact situations where control matters most.
## The design goal
The design goal is not to pretend failure disappears.
The design goal is to make budget governance remain meaningful even when failure occurs.
That means the system should strive to ensure that:
- the same logical action is not charged multiple times by accident
- concurrent actions cannot overrun budget due to naive race conditions
- partial execution can be reconciled explicitly
- reservations do not leak forever
- retries do not make accounting non-deterministic
- enforcement remains understandable under load
This is what separates a production control layer from a reporting wrapper.
## Summary
Autonomous systems operate in an environment shaped by retries, crashes, duplicates, and concurrency.
Any budget control model that ignores these realities will eventually produce drift, ambiguity, or broken enforcement.
That is why Cycles is built around:
- reservation before execution
- commit of actual usage afterward
- release of unused remainder
- idempotent lifecycle handling
- concurrency-aware budget enforcement
- hierarchical policy evaluation across scopes
These are not implementation details.
They are the difference between “tracking usage” and **governing execution under real production conditions**.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
- [5 Real-World AI Agent Failures That Budget Controls Would Have Prevented](/blog/ai-agent-failures-budget-controls-prevent) — concrete incidents involving retry storms and concurrency races
- [From Observability to Enforcement](/concepts/from-observability-to-enforcement-how-teams-evolve-from-dashboards-to-budget-authority) — how teams evolve from dashboards to runtime authority
- [Cycles vs Custom Token Counters](/concepts/cycles-vs-custom-token-counters) — why ad hoc counters break under the concurrency and retry patterns discussed here
# Runtime Authority vs Runtime Authorization
Two governance terms have started circulating in the AI agent ecosystem, and they sound like the same thing. They aren't.
> **Runtime *authorization*** asks whether an identity is *allowed* to use a tool.
> **Runtime budget authority** asks whether the caller's submitted amount fits the matching ledgers for this next step.
**Authorization grants access; budget authority meters caller-submitted exposure.**
A production agent stack needs both. They sit at different layers, fire at different moments, and bound different things. AWS AgentCore Policy, Akeyless Agentic Runtime Authority, and internal agent-IAM patterns focus on identity, intent, access, and real-time policy enforcement — they decide whether an agent identity, intent, and request context are *permitted* to use a given tool or system. Cycles focuses on bounded exposure: whether a caller-assigned amount can still be reserved against the relevant scoped budget. The caller can express exposure as money, tokens, credits, or `RISK_POINTS`; the current Cycles server does not infer risk or classify tools itself.
The term "runtime authority" is used by multiple vendors with overlapping but different scopes. In Cycles, the concrete decision is narrower: *can this amount be reserved against the configured ledgers now?* The host combines that answer with identity and tool authorization.
## The two questions, side by side
| | Runtime Authorization | Runtime Authority (Cycles) |
|---|---|---|
| **What it answers** | "Is this identity allowed to call this tool?" | "Can this submitted amount be reserved against the matching budgets?" |
| **When it fires** | At identity-resolution time, per tool invocation | At every reservation, before each costly action |
| **What it bounds** | Static policy — which identities can touch which tools | Dynamic budget — total spend or caller-assigned exposure such as credits and risk points |
| **What it does NOT cover** | Cumulative consumption, hierarchical scopes, atomic concurrency | Identity-to-tool mapping, credential management, secret rotation |
| **Decision model** | ALLOW / DENY based on identity and policy | Preflight: ALLOW / [ALLOW_WITH_CAPS](/blog/what-is-runtime-authority-for-ai-agents) / DENY based on scoped budget evaluation; live reserve: ALLOW / ALLOW_WITH_CAPS or an error |
| **State** | Stateless policy lookup (typically) | Persistent budget ledger with [reserve-commit lifecycle](/protocol/how-reserve-commit-works-in-cycles) |
Both layers fire pre-execution. They're complementary — neither makes the other redundant.
## Where each fits in the production stack
A real agent action goes through both layers in sequence:
```text
1. Agent decides to call tool X
2. AUTHORIZATION: "Is this agent identity allowed to invoke X?"
↓ Yes (or DENY → caller informed)
3. BUDGET AUTHORITY: "Can the caller-assigned exposure be reserved for this action?"
↓ ALLOW or ALLOW_WITH_CAPS (or a budget error → graceful degradation)
4. Execute tool with the constraints from authority's caps
5. Authority commits actual cost, releases unused budget
```
Skip layer 2 and an agent with credentials may reach tools it should not use. Skip layer 3 and an authorized agent has no Cycles ledger bounding cumulative submitted spend or exposure.
## Where adjacent tools fit
We don't ship per-vendor comparison pages against the identity-based agent governance tools — they solve a different problem, and head-to-head framing implies substitution where the right framing is composition. But you should know how Cycles overlaps with what's emerging in this space.
| | Identity / intent-scoped tool access | Per-action risk budget | Pre-execution cost authority | Reserve-commit semantics | Self-hosted, no prompt storage |
|---|:---:|:---:|:---:|:---:|:---:|
| AWS Bedrock AgentCore Policy | Yes | Not publicly documented | Not publicly documented | Not publicly documented | AWS-managed |
| Akeyless Agentic Runtime Authority | Yes — intent-aware access / real-time policy | Not publicly documented | Not publicly documented | Not publicly documented | Cloud / vendor-managed |
| Generic agent IAM patterns | Yes | Usually no | Usually no | No | Varies |
| **Cycles** | API permissions only; downstream tool IAM external | **Caller-assigned RISK_POINTS budget** | **Yes** | **Yes** | **Yes** |
The first column is the authorization / intent-policy layer. AgentCore and Akeyless are well-suited for it — they handle identity, intent-aware access, policy attachment, and credential governance. The middle columns are the bounded-exposure layer — that is where Cycles operates. The final column is a deployment / privacy distinction, not a runtime-authority capability per se.
## Better together
A production stack wires both layers in the order shown above. Cycles supplies API keys with permission scopes (`reservations:create`, `balances:read`, `admin:write`, etc.) for the runtime plane, and identity-based authorization tools handle the upstream question of whether the agent identity is allowed to obtain those keys in the first place.
Concrete example — a SaaS deploying customer-support agents:
- **Authorization layer** (AgentCore / Akeyless / IAM): defines that *the support agent's identity* is allowed to call the `send_email` tool, and *the engineering agent's identity* is allowed to call the `deploy_service` tool. Cross-access denied at the policy layer.
- **Authority layer** (Cycles + host integration): defines that the *support tenant* has $500/month in tokens and a 200-point daily risk budget. The host classifies each email as 40 [RISK_POINTS](/concepts/action-authority-controlling-what-agents-do) and reserves that amount before dispatch. Even though the support agent is *authorized* to send emails, the 6th live reservation fails once that risk budget is exhausted; an LLM call likewise does not proceed when its token reservation fails.
Without authorization, a credentialed caller may reach tools it should not use. Without a cumulative budget or count control, an authorized agent can repeat a tool until some other limit stops it.
## When you only need authorization
- Single-tool agents with low blast radius (read-only, no concurrency, no multi-tenancy).
- Internal-only deployments where the question is "who's allowed to use this tool" and there's no budget to bound.
- Pre-production prototypes where cumulative cost isn't yet a concern.
If you're here, AgentCore Policy or a similar identity-based system is sufficient — Cycles adds overhead you don't need yet.
## When you need authority
- Multi-tenant SaaS where one customer's runaway must not affect other tenants.
- Agents with hierarchical standard scopes—tenant → workspace → app → workflow → agent → toolset—that need multiple budget ledgers. A run can be represented by a unique workflow value when it needs its own ledger.
- Tools with side effects (email, deploy, mutation) where you want to bound risk *separately* from cost.
- Multi-agent delegation chains where authority should attenuate at each hop, not propagate.
- Production cost predictability — you need evidence that configured budgets bound covered execution paths under concurrency and retries.
If any of these apply, identity authorization alone leaves the budget and risk dimensions unbounded. That's where Cycles fits.
## Sources
- [Policy in Amazon Bedrock AgentCore — Control Agent-to-Tool Access](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/policy.html) — AWS documentation on AgentCore Policy enforcement before tool execution.
- [Akeyless launches Runtime Authority for AI Agents](https://www.akeyless.io/press-release/akeyless-launches-runtime-authority-for-ai-agents/) — Akeyless announcement framing identity-aware enforcement as runtime authority.
External vendor capabilities verified against linked sources as of July 2026. These tools evolve quickly — check the linked docs for the latest. Cycles capability statements describe the currently shipped server; protocol-only action-governance features are identified separately in the linked action-authority documentation.
## Related
- [Cycles Protocol](/protocol/) — the open specification behind the runtime-authority claim. Explicit conformance criteria and the reference implementation are public.
- [What Is Runtime Authority for AI Agents](/blog/what-is-runtime-authority-for-ai-agents) — the canonical definition we use throughout Cycles documentation.
- [Action Authority — Controlling What Agents Do](/concepts/action-authority-controlling-what-agents-do) — composing host authorization with caller-assigned RISK_POINTS and host-applied caps.
- [Comparisons — How Cycles Differs from Alternatives](/concepts/comparisons) — proxy/observability/rate-limit comparison hub for the LiteLLM/Helicone/LangSmith axis.
- [Why Rate Limits Are Not Enough](/concepts/why-rate-limits-are-not-enough-for-autonomous-systems) — the deeper argument for why velocity controls and identity policy alone fail for autonomous systems.
# Webhooks and Events
Cycles services emit **events** from implemented budget, reservation, tenant, API-key, webhook, and system hooks. The schema also registers planned event types that are not emitted yet; the [Event Payloads Reference](/protocol/event-payloads-reference) tracks that status. **Webhooks** deliver emitted events to external endpoints via HTTP POST with HMAC-SHA256 signatures.
## Core Concepts
### Events
An event is an immutable record of an emitted decision, lifecycle outcome, or state change. Every event has:
- **event_id** — unique identifier; the key consumers dedupe on
- **event_type** — dotted format like `budget.exhausted` or `reservation.denied`
- **category** — one of: budget, reservation, tenant, api_key, policy, webhook, system
- **timestamp** — when the event occurred
- **tenant_id** — which tenant is affected
- **source** — which service emitted it (an open string; current reference values include `cycles-server`, `cycles-admin`, and `cycles-events`)
- **data** — optional event-specific payload (varies by type)
Events are stored in Redis with a 90-day TTL (configurable).
### Webhook Subscriptions
A subscription defines which events to deliver and where:
- **url** — HTTPS endpoint to receive HTTP POST requests
- **event_types** — specific events to receive (e.g., `["budget.exhausted", "reservation.denied"]`)
- **event_categories** — receive all events in a category (additive with event_types)
- **scope_filter** — optional scope-path filter; only events whose scope matches are delivered (see [Webhook Scope Filter Syntax](/protocol/webhook-scope-filter-syntax))
- **signing_secret** — HMAC-SHA256 key for payload verification
### Delivery Semantics
- **At-least-once** — events may be delivered more than once. Deduplicate using `event_id`.
- **No delivery-order guarantee** — concurrent delivery workers and retries can reorder deliveries. Use `event_id` only for deduplication; reconstruct chronology from event timestamps, the stored event log, and current domain state.
- **Non-blocking** — webhook delivery never blocks the API operation that produced the event.
- **Retry with backoff** — failed deliveries retry with exponential backoff (default: 5 retries).
- **Auto-disable** — subscriptions are disabled after consecutive failures (default: 10).
## Architecture
The events service is **optional**. If not deployed, events accumulate in Redis with TTL and are delivered when the service starts.
## 51 Registered Event Types
| Category | Count | Registered examples (some remain planned) |
|---|---|---|
| budget | 17 | `budget.exhausted`, `budget.threshold_crossed`, `budget.over_limit_entered`, `budget.funded`, `budget.closed_via_tenant_cascade` |
| reservation | 6 | `reservation.denied`, `reservation.commit_overage`, `reservation.released_via_tenant_cascade` |
| tenant | 6 | `tenant.created`, `tenant.suspended`, `tenant.closed` |
| api_key | 7 | `api_key.created`, `api_key.revoked`, `api_key.revoked_via_tenant_cascade` |
| policy | 3 | `policy.created`, `policy.updated`, `policy.deleted` |
| webhook | 7 | `webhook.created`, `webhook.paused`, `webhook.disabled_via_tenant_cascade` |
| system | 5 | `system.store_connection_lost`, `system.webhook_delivery_failed` |
The four `*_via_tenant_cascade` types were added to the enum in governance spec revision v0.1.25.35.
## Tenant Self-Service
Tenants can create their own webhook subscriptions via `/v1/webhooks` (requires `webhooks:write` permission). Tenant webhooks are restricted to budget, reservation, and tenant events: 29 of the 51 registered event types — including the `budget.*` and `reservation.*` `_via_tenant_cascade` fan-out events emitted during a tenant close (see [Tenant-Close Cascade Semantics](/protocol/tenant-close-cascade-semantics)). API key, policy, webhook lifecycle, and system events are admin-only: a tenant-owned subscription can neither carry them nor receive them from the event stream, enforced at write, dispatch, and last-mile delivery (governance WEBHOOK SUBSCRIPTION INVARIANT 2; the one exception is the owner-triggered `/test` probe — see [Tenant-accessible events](/protocol/webhook-event-delivery-protocol#tenant-accessible-events)).
## Security
- **HMAC-SHA256** — every delivery includes `X-Cycles-Signature: sha256=` for payload verification
- **Encryption at rest** — signing secrets are encrypted in Redis with AES-256-GCM using `WEBHOOK_SECRET_ENCRYPTION_KEY`; admin and events fail startup without the key unless plaintext is explicitly enabled for local development
- **SSRF prevention** — private IP ranges blocked by default, HTTPS required in production
## Learn More
- [Webhook Integrations Guide](/how-to/webhook-integrations) — PagerDuty, Slack, ServiceNow examples with code
- [Security Hardening](/how-to/security-hardening) — webhook URL security and secret rotation
- [Production Operations](/how-to/production-operations-guide) — events service deployment and failure handling
# What Cycles Is Not: Billing, Rate Limiting, Orchestration, and Other Category Confusion
When people first encounter Cycles, they often try to map it onto an existing category.
That is normal.
Most infrastructure projects are easier to understand when they resemble something familiar. So the first questions are often:
- Is this just billing?
- Is this a rate limiter?
- Is this workflow orchestration?
- Is this observability for AI usage?
- Is this a policy engine?
- Is this a gateway or proxy?
- Is this an AI safety product?
The honest answer is:
**Cycles overlaps with some of these categories, but it is not reducible to any of them.**
Cycles is a **runtime authority for autonomous agents**.
That means it exists to decide whether autonomous work is allowed to proceed, how much bounded exposure it may reserve, and how that usage is reconciled afterward.
This article explains what Cycles is **not**, and why those distinctions matter.
## Cycles is not billing
Billing tells you what to charge.
Cycles tells you what may execute.
Billing usually answers questions like:
- how much did this customer use?
- what invoice should we generate?
- how should usage be priced?
- how should this appear in finance or revenue systems?
Cycles is not designed to replace that.
Cycles operates earlier in the chain.
It answers questions like:
- may this action proceed right now?
- how much exposure can it reserve before execution?
- what scope should this count against?
- what happens if the budget is exhausted?
- what should be committed as actual usage after the action completes?
Billing is usually retrospective.
Cycles is pre-execution and execution-aware.
The two can work together, but they are not the same thing.
## Cycles is not rate limiting
Rate limiting controls **velocity**.
Cycles controls **total bounded exposure**.
A rate limiter might say:
- 100 requests per minute
- 10 tool invocations per second
- 1,000 API calls per hour
That is useful for abuse prevention, fairness, and traffic shaping.
But it does not answer:
- how much total budget may this run consume?
- may this tenant continue if it already exhausted its daily budget?
- should this workflow continue after repeated retries?
- should this tool call proceed if the run is almost out of budget?
An agent can stay perfectly inside its request-per-second threshold and still burn through budget over time.
That is why rate limiting and Cycles solve different problems.
You should usually keep your rate limiter.
Just do not confuse it with budget enforcement.
## Cycles is not observability
Observability tells you what happened.
Cycles helps determine what is allowed to happen.
Observability tools are essential. They help teams answer questions like:
- which workflows are expensive?
- where did retries occur?
- what was the cost distribution?
- which tenant used the most resources?
- what failed, when, and why?
Cycles benefits from good observability.
It does not replace it.
But observability alone does not create control.
A dashboard may tell you that a runaway workflow consumed too much budget.
Cycles is about introducing a control point **before and during execution**, so that work can be bounded instead of merely explained later.
That is the difference between reporting and governance.
## Cycles is not orchestration
Workflow orchestration decides **what should happen next**.
Cycles decides **whether bounded execution is allowed to continue**.
An orchestrator might manage:
- task sequencing
- retries
- step dependencies
- state transitions
- fan-out and fan-in
- compensation logic
Cycles does not replace that.
It is not trying to become the workflow engine.
Instead, Cycles sits alongside execution and asks:
- can this next step reserve enough budget?
- should this tool path be allowed?
- should this run continue?
- should the system degrade instead of proceed normally?
That makes Cycles complementary to orchestration, not a substitute for it.
## Cycles is not a generic policy engine
Policy engines evaluate rules.
Cycles enforces budget-aware execution semantics.
A generic policy engine may be able to express conditions like:
- user role is admin
- environment is production
- action type is write
- tenant plan is premium
Cycles can certainly work with policy logic.
But its core purpose is narrower and more operational.
It is concerned with things like:
- reservation
- commit
- release
- balances
- bounded execution
- hierarchical budget scopes
- retry-safe lifecycle semantics
In other words, Cycles is not just “if this then allow/deny.”
It is a control model for autonomous work that consumes budgeted exposure over time.
## Cycles is not merely a gateway or proxy
A gateway can be one deployment surface for Cycles.
It is not the category itself.
You could embed Cycles behind:
- an LLM gateway
- an API proxy
- a service mesh boundary
- a workflow runtime
- an application SDK
Those are all valid places to integrate.
But Cycles is not simply “a proxy that counts requests.”
Its control model is richer than request forwarding.
It needs to support:
- reserve before execution
- commit actual usage afterward (auto-releasing unused remainder)
- release explicitly on cancellation
- hierarchical scope enforcement
- retry-safe and idempotent behavior
A proxy may carry these semantics.
But the semantics are the important part.
## Cycles is not AI safety in the broad philosophical sense
Cycles can reduce certain classes of operational risk.
It is not a general solution to AI safety.
Cycles does not claim to solve:
- hallucinations
- alignment
- truthfulness
- harmful content generation
- model bias
- broad social safety concerns
Its scope is narrower.
Cycles helps govern **cost, side effects, and bounded execution** in autonomous systems.
That includes questions like:
- how much can this system spend?
- what irreversible actions may it take?
- what should happen when the budget is exhausted?
- how do we keep retries and loops from becoming unbounded incidents?
That is valuable.
It is also specific.
Keeping that boundary clear makes the project more credible, not less.
## Cycles is not just cost tracking
Cost tracking answers:
- how much did we spend?
Cycles answers:
- how much are we willing to let this execution risk before it proceeds?
That distinction is subtle but important.
Tracking is passive.
Governance is active.
A post-hoc cost report may help you improve later.
A budget authority can stop a run before it becomes a larger incident.
## Cycles is not the same as quotas
Quotas are static boundaries.
Cycles provides a runtime lifecycle.
A quota might say:
- this tenant gets 10,000 units per day
- this workflow gets 500 units per run
That is useful.
But autonomous systems also need a way to manage execution as it happens:
- reserve estimated exposure
- execute work
- commit actual usage (auto-releases unused remainder)
- release explicitly on cancellation
- handle retries safely
- reconcile partial completion
That is the part Cycles focuses on.
Quotas are part of policy.
Cycles is the runtime discipline that makes policy operational.
## Cycles is not a token or rewards scheme
The name can sometimes lead people in the wrong direction.
Cycles is not a speculative asset, loyalty point, or incentive token.
It is an accounting and governance primitive.
A Cycle is an operator-defined unit of bounded exposure.
That unit may represent cost, side-effect potential, or some normalized execution budget. But its role is operational, not financialized.
The point is to make autonomous systems governable, not tradable.
## So what is Cycles, exactly?
The shortest answer is:
**Cycles is a runtime authority for autonomous agents.**
More specifically, it is a protocol and runtime model for:
- reserving bounded exposure before work starts
- committing actual usage after work completes
- releasing unused remainder
- enforcing limits across scopes such as tenant, workflow, and run
- remaining meaningful under retries, duplicates, crashes, and concurrency
That makes it adjacent to several existing categories, but not identical to any one of them.
## Why this distinction matters
Category confusion is not just a messaging problem.
It leads to the wrong adoption expectations.
If someone thinks Cycles is billing, they will ask where invoices are.
If someone thinks it is rate limiting, they will judge it against request throttling.
If someone thinks it is orchestration, they will expect workflow graphs and schedulers.
If someone thinks it is observability, they will look for dashboards first.
Those are all reasonable expectations for those categories.
They are the wrong expectations for Cycles.
The right expectation is:
**Cycles helps teams bound and govern autonomous execution before it becomes unbounded cost or side effect.**
That is the job.
## When Cycles is the right fit
Cycles is a good fit when a system needs more than reporting or traffic shaping.
For example:
- long-running agent loops
- tool-calling workflows
- multi-tenant AI platforms
- background autonomous jobs
- systems where retries and fan-out affect cost materially
- systems where side effects need budget-aware control
- systems that need pre-execution budget checks, not just dashboards
In these cases, the gap is usually not visibility.
It is the absence of a runtime authority.
## Summary
Cycles is related to billing, rate limiting, observability, orchestration, and policy.
But it is not any one of those things.
It is its own control layer.
That control layer exists to make autonomous execution:
- explicit
- bounded
- budget-aware
- retry-safe
- enforceable across scopes
That is why the best way to think about Cycles is not as a dashboard, a proxy, or a scheduler.
It is a **runtime authority for autonomous agents**.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
- [AI Agent Cost Management: The Complete Guide](/blog/ai-agent-cost-management-guide) — the maturity model from monitoring to hard enforcement, and why Cycles is specifically Tier 4
- [Cycles vs Rate Limiting](/concepts/cycles-vs-rate-limiting) — the detailed comparison between rate limiting and runtime authority
- [Runtime Authority vs Runtime Authorization](/concepts/runtime-authority-vs-runtime-authorization) — Cycles is also not runtime authorization. AWS Bedrock AgentCore Policy and Akeyless answer "is this identity allowed"; Cycles answers "does this agent still have bounded permission to take this next step".
- [Comparisons](/concepts/comparisons) — how Cycles differs from rate limiters, provider caps, observability tools, and more
# Why Coding Agents Do Not Replace Cycles
The real risk with coding agents is not that they fail to produce work.
It is that they make production cheap enough to blur the line between **more output** and **more value**.
When implementation gets dramatically easier, teams do not automatically become more disciplined. In many cases, they become less so. Scope expands. Nice-to-haves slip in. Work that would once have been deferred now feels cheap enough to keep going.
That is where Cycles matter.
This article is about the **business-layer governance problem**: how teams decide what work is worth funding, where scope should stop, and when priorities should be re-evaluated.
The runtime-layer problem is different. It asks how an autonomous system is prevented from exceeding its allowed execution budget in the middle of a run — under retries, parallelism, partial failure, and tool fan-out. That is covered separately in [Coding Agents Need Runtime Authority](/concepts/coding-agents-need-runtime-budget-authority).
The two are complementary, not interchangeable.
Coding agents increase execution capacity.
Runtime authority bounds execution.
Cycles govern whether the work was worth funding in the first place, and whether the next unit of work deserves more budget.
## Agents optimize for output. Cycles optimize for value.
A coding agent's job is to complete the task it was given.
Give it a prompt and it will try to produce output: code, tests, documentation, refactors, fixes, follow-up patches. That is what makes it useful.
But output and value are not the same thing.
A team can now generate more implementation work than ever before with less friction than ever before. The bottleneck is no longer only execution. Increasingly, the bottleneck is deciding what is actually worth continuing, polishing, expanding, or funding.
That is where Cycles matter.
A Cycle creates a forcing function around value, not just activity. It establishes a bounded unit of committed work and a deliberate checkpoint afterward: was the outcome worth the spend, did the scope remain justified, and what should receive budget next?
A coding agent can help produce more output within that boundary. It cannot create the boundary, own the tradeoff, or decide whether the result justified further investment.
## The hidden cost of cheap output
When output becomes cheaper, scope tends to expand.
That is not because teams become irrational. It is because each incremental addition starts to feel inexpensive in isolation.
Add the extra edge case.
Support one more path.
Polish the interface.
Refactor the surrounding module.
Generate another round of tests.
Handle one more environment.
Each decision can sound reasonable on its own. The problem is cumulative. When the friction of implementation falls, the friction that used to enforce prioritization disappears with it.
That is how teams drift from a small, valuable deliverable into a much larger body of work that no one explicitly decided was worth funding.
Cycles restore that missing discipline.
They provide a business boundary: this is what we are funding now, this is what counts as done for this period, and this is the checkpoint before more scope is authorized.
That is a different problem from runtime enforcement inside a single autonomous run. If the question is what happens when an agent retries, fans out, or exceeds its allowed execution budget mid-run, see [Coding Agents Need Runtime Authority](/concepts/coding-agents-need-runtime-budget-authority). If the question is whether the team should continue funding the next increment of work at all, that is the role Cycles play.
## Spend visibility is not value accountability
Agents make usage easier to measure.
You can inspect token consumption, tool calls, session traces, CI minutes, and provider bills with much greater precision than before. That visibility is useful. It helps explain what happened.
But it still does not tell you whether the output was worth the cost.
That judgment does not emerge automatically from logs, traces, or model invoices. It remains a human governance decision.
Someone still has to decide:
Was this worth the spend?
Did this work move the priority that mattered?
Should this area receive more budget, or should the team stop here?
Did the agent help compress valuable work, or did it simply make it easier to produce more of it?
Cycles exist to force that conversation at a predictable boundary.
Without a structure like that, teams can become highly efficient at producing output while becoming much less disciplined about deciding whether that output deserved to exist.
## Cycles get cheaper. They do not go away.
The right reframe is simple:
::: info
Coding agents do not eliminate Cycles. They reduce the cost of executing within them.
:::
If a team previously needed six engineers to hit a given scope and can now hit the same scope with four plus coding agents, that is a real productivity gain. But the Cycle still matters. The checkpoint still matters. The prioritization still matters. The budget decision still matters.
What changed is not the need for governance.
What changed is the cost curve inside the governance structure.
That is where the real upside of coding agents shows up. Teams that understand this use automation to make each governed iteration faster and cheaper. Teams that ignore it often confuse increased output with increased progress, then discover later that they have accumulated a large body of work with weak linkage to business outcomes.
## Runtime control and business control are not the same
It is worth stating the distinction directly.
Runtime authority answers questions like:
- Can this next autonomous step proceed?
- Should this run be denied, degraded, or stopped?
- What happens under retries, concurrency, and partial failure?
- How is budget reserved, committed, and released during execution?
That is an execution-layer control problem.
Cycles answer a different set of questions:
- Was this slice of work worth funding?
- Should we keep investing here?
- Did the delivered output justify the committed spend?
- What deserves budget next?
That is a business-layer governance problem.
Both matter.
If you have runtime control without Cycles, you may prevent overruns inside execution while still funding the wrong work.
If you have Cycles without runtime control, you may make good planning decisions while still allowing autonomous runs to exceed safe limits in practice.
The systems complement each other because they constrain different failure modes.
## The more interesting future: budgeting outcomes, not just features
As teams get better at using coding agents, the natural next step is not simply to ship more tickets.
It is to become more explicit about the outcome being funded.
Instead of thinking only in terms of budget per feature, more mature teams will increasingly think in terms of budget per outcome: move this metric, improve this workflow, reduce this latency, increase this conversion, lower this support burden.
That is where Cycles become even more useful.
A Cycle boundary is a natural point to ask not just whether the implementation was completed, but whether the work moved the thing that mattered. As coding agents make delivery cheaper, outcome discipline becomes more important, not less. Otherwise teams risk becoming extremely efficient at completing tasks that should not have received additional budget.
## Bottom line
Coding agents are a force multiplier on execution.
Runtime authority ensures autonomous execution stays bounded while it is happening.
Cycles provide the governance structure above that layer: the discipline that asks whether the work was worth funding, whether scope should stop, and what should receive budget next.
You need all three ideas if you want agentic software to be both fast and economically coherent.
If you want the runtime-side companion to this piece — reservations, enforcement, retries, concurrency, and bounded execution inside a single agent run — see [Coding Agents Need Runtime Authority](/concepts/coding-agents-need-runtime-budget-authority).
The teams that get the most out of coding agents will not be the ones that simply generate the most output.
They will be the ones that pair machine-speed execution with explicit runtime control and deliberate budget judgment.
## Next steps
To learn more:
- Read [Coding Agents Need Runtime Authority](/concepts/coding-agents-need-runtime-budget-authority) for the runtime-layer companion to this piece
- Understand [Why Rate Limits Are Not Enough](/concepts/why-rate-limits-are-not-enough-for-autonomous-systems) for how velocity controls differ from runtime authority
- See [From Observability to Enforcement](/concepts/from-observability-to-enforcement-how-teams-evolve-from-dashboards-to-budget-authority) for how teams evolve from dashboards to budget governance
- Explore the [reserve-commit lifecycle](/protocol/how-reserve-commit-works-in-cycles) that powers runtime enforcement
- Get started with the [Python Client](/quickstart/getting-started-with-the-python-client) or [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- [AI Agent Budget Control: Enforce Hard Spend Limits](/blog/ai-agent-budget-control-enforce-hard-spend-limits) — the technical mechanism behind runtime budget enforcement
# Why Rate Limits Are Not Enough for Autonomous Systems
Autonomous systems do not fail like traditional software.
They do not simply receive a request, process it once, and return a response.
They loop.
They retry.
They fan out across tools and models.
They continue after partial failure.
They make decisions that create cost and side effects over time.
That changes the control problem.
For traditional APIs, controls like rate limits, quotas, and timeouts are often enough. They help bound request velocity and reduce abuse.
For autonomous systems, they are not enough.
## The real problem is not speed
Rate limits answer a narrow question:
**How fast can this system act?**
Autonomous systems introduce a different question:
**How much total exposure is this system allowed to create?**
That exposure may include:
- LLM usage and token spend
- external API calls
- database writes
- message dispatch
- payment instructions
- workflow fan-out
- tool invocations with irreversible side effects
A system can remain within its request-per-second threshold and still create unacceptable cost or damage over time.
That is why teams often discover the problem too late.
Not when the first request succeeds.
Not when latency rises.
But when the bill arrives, a workflow loops indefinitely, a tool runs recursively, or one tenant quietly consumes more than intended.
## Why rate limits fail in practice
Rate limits are useful. They should not be removed.
They are just solving a different problem.
Here are some common failure cases where rate limits are insufficient.
### 1. Loops stay within allowed velocity
An agent can call a model every few seconds, remain fully within rate limits, and still burn through budget over hours.
Nothing is “spiking.”
Nothing looks like abuse.
The system is simply allowed to continue.
### 2. Retries multiply total cost
A failed step retries.
Then retries again.
Then downstream steps retry too.
Each individual request may be valid.
The accumulated exposure is not.
### 3. Tool calls create hidden fan-out
A single high-level action can expand into:
- multiple model calls
- several external APIs
- database writes
- follow-up jobs
- additional agent steps
Rate limits see individual calls.
They do not naturally bound the full execution chain.
### 4. Per-request controls ignore tenant-level consumption
A multi-tenant platform may cap each request correctly but still fail to enforce what one tenant is allowed to consume over a run, workflow, or billing window.
### 5. Post-hoc observability is not enforcement
Dashboards can show what happened after the fact.
That is useful for analysis.
It is not the same as deciding, before execution, whether an action is allowed to proceed.
## Autonomous systems need runtime authority
The missing primitive is not better logging.
It is not another dashboard.
It is a way to make autonomous work ask for bounded room to act **before** it acts.
That requires a different control model:
1. declare intent
2. reserve budget
3. execute
4. commit actual usage or release the remainder
This is the model behind Cycles.
## The Cycles model
Cycles introduces deterministic budget control for autonomous execution.
Instead of discovering cost and side effects only after they occur, a system reserves bounded exposure before work begins.
At a high level:
- an action declares expected usage
- budget is reserved against one or more scopes
- work executes only if reservation succeeds
- actual usage is committed afterward (unused remainder is released automatically)
- or the reservation is released explicitly if work is canceled
This changes the control surface from:
::: info
“observe what happened”
:::
to:
::: info
“authorize bounded execution, then reconcile actual usage”
:::
That difference matters under retries, crashes, concurrency, and long-running workflows.
## Why reserve and commit are different from simple quotas
A quota says:
::: info
you may use up to this much over time
:::
A reserve-commit model says:
::: info
this execution is allowed to consume up to this bounded amount now
:::
That makes several important things possible.
### Bounded execution before work starts
If the system cannot reserve enough budget, the action can be denied, degraded, or rerouted before cost is incurred.
### Safer retries
If retries are idempotent and tied to the same reservation lifecycle, the system can avoid accidental double-spend.
### Actuals instead of guesswork
Many systems can estimate cost before execution but only know the true cost afterward.
Reserve-commit handles both.
### Hierarchical control
A single action may need to satisfy limits at multiple levels:
- tenant
- workspace
- app
- workflow
- agent
- toolset
This is hard to model cleanly with flat quotas alone.
## A concrete example
Imagine a customer support agent that can:
- call an LLM
- query a CRM
- search a knowledge base
- send an email
- open a ticket
- trigger a follow-up workflow
A rate limiter can throttle each component.
But it does not answer:
- how much total budget is this run allowed to consume?
- how much can this tenant spend today?
- should this workflow continue if prior retries already consumed most of its budget?
- should the system downgrade from a larger model to a smaller one?
- should the email or ticket-creation step be blocked once the run is over budget?
Those are runtime authority questions, not velocity questions.
## What teams usually do today
Most teams solve this in ad hoc ways:
- model-specific caps
- provider dashboards
- cron-based alerts
- tenant usage counters
- best-effort checks inside business logic
- manual kill switches
- custom retry heuristics
These can help, but they are often fragmented and hard to make correct under concurrency.
The result is usually one of two extremes:
- controls are too weak and failures become expensive
- controls are too rigid and autonomous systems become brittle
A proper runtime authority gives teams a cleaner middle ground.
## What Cycles is for
Cycles is designed for teams building systems where autonomous software can create real cost or irreversible side effects.
That includes:
- agent loops
- tool-calling workflows
- long-running background execution
- multi-tenant AI platforms
- budget-sensitive inference systems
- infrastructure that must hold up under retries and partial failure
Cycles is not a billing dashboard.
It is not just observability.
It is not a rate limiter.
It is a control layer for bounded autonomous execution.
## The practical takeaway
Keep your rate limits.
But do not confuse them with budget enforcement.
Rate limits are good at controlling speed.
Autonomous systems also need controls for total exposure.
That means introducing a system that can:
- reserve budget before work starts
- commit actual usage afterward (auto-releasing unused remainder)
- release explicitly on cancellation
- apply limits across scopes
- remain safe under retries and concurrency
That is the problem Cycles exists to solve.
## Next steps
To learn more:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage tenants and budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
- [The True Cost of Uncontrolled AI Agents](/blog/true-cost-of-uncontrolled-agents) — real-world failure modes and costs of running agents without budget limits
---
# Protocol Reference
# Action Governance Preview in Cycles
::: warning Preview status
The v0.1.26 action-governance specs are upcoming extension specs. The active v0.1.25 conformance target is still the required implementation surface, and the current reference servers do not enforce v0.1.26 action quotas or action-kind allow/deny lists at runtime.
Some v0.1.25.x admin endpoints accept forward-compatible query parameters described below, but those parameters are compatibility hooks until a server implements the v0.1.26 extension semantics.
:::
Action governance adds a second authority layer alongside spend budgets: a server can meter and block by **what kind of action** an agent is about to execute, not only by how much budget the action consumes.
Budgets still answer "can this tenant spend more?" Action governance answers questions such as:
- Can this agent send email at all?
- Has this run already used its five `message.email.send` calls?
- Are high-risk actions limited more tightly than read-only actions?
- Can this policy be observed in shadow mode before it starts blocking production traffic?
## Spec Files
The preview is split across three YAML specs in [`runcycles/cycles-protocol`](https://github.com/runcycles/cycles-protocol):
| Spec | Owns |
|---|---|
| [`cycles-action-kinds-v0.1.26.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/cycles-action-kinds-v0.1.26.yaml) | Canonical action-kind registry, risk classes, quota windows, action quota schemas, `GET /v1/action-kinds`, `GET /v1/action-kinds/{kind}`, `GET /v1/admin/action-quota-counters`, and `POST /v1/admin/action-quota-counters/reset` |
| [`cycles-protocol-extensions-v0.1.26.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/cycles-protocol-extensions-v0.1.26.yaml) | Runtime behavior: `DenyDetail`, `ObserveModeEnum`, action-governance reason codes, observed events, quota events, and full reservation evaluation order |
| [`cycles-governance-extensions-v0.1.26.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/cycles-governance-extensions-v0.1.26.yaml) | Admin-plane extensions: `Policy.action_quotas`, `Policy.risk_class_quotas`, `Policy.allowed_action_kinds`, `Policy.denied_action_kinds`, `Tenant.observe_mode`, overview aggregates, and preview list filters |
The YAML files remain the authority. This page is the human-readable map.
## Action Kinds
An action kind is a stable string for the operation an agent is about to perform, such as `llm.completion`, `web.search`, `message.email.send`, or `code.exec`.
Each kind belongs to an `ActionRiskClass`:
| Risk class | Meaning |
|---|---|
| `read_only` | Reads state without mutation |
| `local_mutation` | Mutates local or reversible state |
| `side_effect` | Produces effects that can drive downstream work |
| `external_side_effect` | Mutates an external system |
| `high_risk` | High-blast-radius action that should usually be explicitly allowed |
Servers implementing the action-kind registry expose:
```bash
curl -G "http://localhost:7878/v1/action-kinds" \
--data-urlencode "risk_class=external_side_effect" \
--data-urlencode "deprecated=false" \
--data-urlencode "limit=50"
```
`GET /v1/action-kinds` is public in the preview spec and supports `risk_class`, `deprecated`, `cursor`, and `limit`. `GET /v1/action-kinds/{kind}` performs an exact lookup:
```bash
curl "http://localhost:7878/v1/action-kinds/message.email.send"
```
## Policy Fields
The governance extension adds four policy fields:
| Field | Purpose |
|---|---|
| `action_quotas` | Per-action count quota rules, such as five email sends per run |
| `risk_class_quotas` | Aggregate count quota rules by risk class, such as two `high_risk` actions per day |
| `allowed_action_kinds` | Allowlist; when non-empty, only listed kinds may pass |
| `denied_action_kinds` | Denylist; listed kinds are blocked before quota and budget checks |
`allowed_action_kinds` and `denied_action_kinds` are mutually exclusive when both are non-empty. A server implementing v0.1.26 must reject that policy shape instead of guessing precedence.
Tenant records also gain `observe_mode` with values `ENFORCE`, `OBSERVE`, and `DISABLED`. `ENFORCE` is normal behavior. `OBSERVE` evaluates the rules and emits observed events without mutating balances or quota counters. `DISABLED` is a synonym for `ENFORCE` in the extension spec.
## Evaluation Order
For a reservation request, v0.1.26 servers evaluate action governance before budget reservation:
1. Action-kind access control from `denied_action_kinds` and `allowed_action_kinds`.
2. Risk-class quotas from `risk_class_quotas`.
3. Per-kind quotas from `action_quotas`.
4. Budget checks and caps from the existing Cycles reserve/decide model.
5. Atomic reservation and counter mutation when the request is allowed and not in observe/dry-run mode.
That order matters. A denied action kind never consumes quota and never reaches the budget check. In `OBSERVE` mode, the server performs the same evaluation but does not mutate balances or quota counters.
## Quota Windows
Action quotas use `ActionQuotaWindow`:
| Window | Use |
|---|---|
| `per_run` | Tied to `Subject.dimensions.run_id`; requires a run id |
| `per_minute_tumbling` | UTC-minute bucket for burst control |
| `per_hour_tumbling` | UTC-hour bucket |
| `per_day_tumbling` | UTC-day bucket |
| `per_tenant_per_day` | Tenant-root daily bucket, independent of nested subject scope |
Per-kind quotas and risk-class quotas use the same window model. A single allowed reservation may increment both a per-kind counter and a risk-class counter when both rules match.
## Counter APIs
Servers implementing the preview expose an operator view of live counters:
```bash
curl -G "http://localhost:7979/v1/admin/action-quota-counters" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
--data-urlencode "tenant_id=acme-corp" \
--data-urlencode "scope=tenant:acme-corp" \
--data-urlencode "action_kind=message.email.send" \
--data-urlencode "window=per_run" \
--data-urlencode "run_id=run_123" \
--data-urlencode "include_zero=false"
```
`GET /v1/admin/action-quota-counters` is a debug/read endpoint for current counter state. The preview adds an `action_quotas:read` permission; v0.1.26 also allows `balances:read` as a deprecated fallback, with the fallback removed in v0.1.27.
Counter reset is intentionally narrow:
```bash
curl -X POST "http://localhost:7979/v1/admin/action-quota-counters/reset" \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-d '{
"tenant_id": "acme-corp",
"scope": "tenant:acme-corp/workspace:prod/agent:support-bot",
"action_kind": "message.email.send",
"window": "per_run",
"window_key": "run_123",
"reason": "incident response: undo double-counted email send"
}'
```
`POST /v1/admin/action-quota-counters/reset` is AdminKeyAuth-only in v0.1.26. It resets exactly one counter identified by `tenant_id`, `scope`, `action_kind`, `window`, and `window_key`; bulk reset is not part of the preview. Risk-class counters use `action_kind="__risk__"` and include `risk_class`. Every successful reset emits a `quota.counter_reset` event.
## Deny And Event Surface
The runtime extension adds `DenyDetail` to denied decisions and conflict errors. For action governance, known reason codes include:
| Reason code | Meaning |
|---|---|
| `ACTION_KIND_DENIED` | The matching policy's denylist blocked the kind |
| `ACTION_KIND_NOT_ALLOWED` | The matching policy's allowlist did not include the kind |
| `ACTION_QUOTA_EXCEEDED` | A per-kind or risk-class quota was exhausted |
When `reason_code=ACTION_QUOTA_EXCEEDED`, `deny_detail.quota_violation` carries structured quota context such as action kind, window, used count, limit, scope, and policy id.
The preview also defines:
- `reservation.observed_denied` for `OBSERVE` evaluations that would deny.
- `reservation.observed_allowed` for `OBSERVE` evaluations that would allow.
- `quota.threshold_approaching` when a quota counter crosses a configured threshold.
- `quota.counter_reset` when an admin resets a counter through the reset API.
## Admin Compatibility Fields
The governance extension reserves several dashboard and list fields. Their semantics are:
| Surface | Field or filter | v0.1.26 meaning |
|---|---|---|
| `GET /v1/admin/overview` | `recent_denials_by_reason` | Count recent denials by reason code, including action-governance reason codes when implemented |
| `GET /v1/admin/overview` | `quota_health` | Summarize action quota counters near or at their limit |
| `GET /v1/admin/overview` | `access_control_stats` | Count policies using allow/deny action-kind lists and related denial activity |
| `GET /v1/admin/overview` | `tenant_counts.in_observe_mode` | Count tenants with `observe_mode != ENFORCE` |
| `GET /v1/admin/tenants` | `observe_mode` | Filter tenants by `DISABLED`, `OBSERVE`, or `ENFORCE` |
| `GET /v1/admin/policies` | `has_action_quotas` | Filter policies with non-empty `action_quotas` or `risk_class_quotas` |
| `GET /v1/admin/policies` | `references_action_kind` | Filter policies whose `action_quotas`, `allowed_action_kinds`, or `denied_action_kinds` mention the given kind; `risk_class_quotas` entries do not match, since they name risk classes rather than kinds |
On v0.1.25.x reference admin servers, `observe_mode`, `has_action_quotas`, and `references_action_kind` are accepted as forward-compatible query parameters but do not narrow results until the v0.1.26 action-governance extension is implemented.
## Implementation Checklist
- Keep the v0.1.25 conformance target green before adding preview behavior.
- Publish `GET /v1/action-kinds` and `GET /v1/action-kinds/{kind}` before allowing policies to reference custom kinds.
- Reject unknown `custom.*` kinds unless the server has an explicit declaration for them.
- Evaluate access control before quotas, and quotas before budget checks.
- Emit observed events in `OBSERVE` mode without mutating balances or quota counters.
- Treat reason-code strings as open values in dashboards and SDKs so future extensions do not break clients.
## Next Steps
- [Protocol Overview](/protocol/) - current conformance target and spec file map.
- [Dry Run and Shadow Mode](/protocol/dry-run-shadow-mode-evaluation-in-cycles) - existing dry-run behavior that observe mode builds on.
- [Error Codes and Error Handling](/protocol/error-codes-and-error-handling-in-cycles) - base and extension denial semantics.
- [Using the Cycles Dashboard](/how-to/using-the-cycles-dashboard) - how overview fields surface in operator workflows.
# API Reference for the Cycles Protocol
This is a developer-friendly reference for the core runtime budget endpoints and public CyclesEvidence retrieval endpoints. The [interactive API reference](/api/) is generated from the YAML spec and remains the exhaustive operation browser.
Tenant-scoped runtime requests require the `X-Cycles-API-Key` header for authentication. Reservation list/detail/release also accept `X-Admin-API-Key` for operator workflows on the small admin-on-behalf-of surface. The two CyclesEvidence read endpoints, `GET /v1/evidence/{evidence_id}` and `GET /v1/.well-known/cycles-jwks.json`, are public by spec because they expose only content-addressed evidence envelopes and public verification keys.
::: info Protocol conformance
Cycles is an **open protocol with a minimum conformance surface**. The active v0.1.25 target requires 12 MUST operations: four core runtime reservation operations plus eight cross-plane event, webhook, balance, and auth-introspection operations. `decide`, reservation listing/detail, and direct-debit events are SHOULD-level runtime operations that the reference servers expose. v0.1.26 action-governance specs are published as upcoming extensions, but they are not required for current conformance and are not enforced by the current reference servers. See [`CONFORMANCE.md`](https://github.com/runcycles/cycles-protocol/blob/main/CONFORMANCE.md) for the authoritative MUST / SHOULD / MAY statement.
:::
## Common headers
### Request headers
| Header | Required | Description |
|---|---|---|
| `Content-Type` | Yes (POST) | `application/json` |
| `X-Cycles-API-Key` | Yes for tenant-scoped runtime endpoints | API key for authentication and tenant derivation |
| `X-Admin-API-Key` | Operator-only on reservation list/detail/release | Admin-on-behalf-of authentication for incident response and inspection |
| `X-Idempotency-Key` | No | Client-provided idempotency key (also accepted in the request body) |
### Response headers
| Header | Description |
|---|---|
| `X-Request-Id` | Unique request identifier for debugging and support |
| `X-Cycles-Trace-Id` | 32-hex W3C Trace Context identifier. Servers MUST echo it on every response (2xx, 4xx, 5xx). The trace ID is taken from an inbound `traceparent` header (preferred) or `X-Cycles-Trace-Id` header when valid, otherwise generated fresh. See [Correlation and Tracing](/protocol/correlation-and-tracing-in-cycles). |
| `X-Cycles-Tenant` | Effective tenant identifier derived from auth context (optional in v0) |
| `X-RateLimit-Remaining` | Number of requests remaining in current window (optional in v0) |
| `X-RateLimit-Reset` | Unix timestamp (seconds) when rate limit resets (optional in v0) |
## Common types
### Subject
The budgeting scope. At least one standard field is required.
```json
{
"tenant": "acme",
"workspace": "production",
"app": "support-bot",
"workflow": "refund-flow",
"agent": "planner",
"toolset": "search-tools",
"dimensions": {
"cost_center": "engineering",
"run_id": "run-12345"
}
}
```
All fields are optional except that at least one of `tenant`, `workspace`, `app`, `workflow`, `agent`, or `toolset` must be present. The `dimensions` field allows arbitrary key-value pairs for alternative taxonomies — attribution, reporting, and policy facets. Dimensions never derive budget scopes, and v0 servers MAY ignore them for budgeting decisions; anything that needs an enforceable budget belongs in one of the six standard fields.
### Amount
```json
{
"amount": 5000,
"unit": "USD_MICROCENTS"
}
```
Units: `USD_MICROCENTS`, `TOKENS`, `CREDITS`, `RISK_POINTS`.
### Action
```json
{
"kind": "llm.completion",
"name": "openai:gpt-4o",
"tags": ["customer-facing", "prod"]
}
```
### Caps (soft constraints)
Returned when the decision is `ALLOW_WITH_CAPS`:
```json
{
"max_tokens": 500,
"max_steps_remaining": 3,
"tool_allowlist": ["search"],
"tool_denylist": ["code_exec"],
"cooldown_ms": 2000
}
```
### Error response
```json
{
"error": "BUDGET_EXCEEDED",
"message": "Insufficient budget in scope tenant:acme",
"request_id": "req-abc-123",
"trace_id": "0af7651916cd43dd8448eb211c80319c",
"details": {}
}
```
---
## POST /v1/reservations
Reserve budget before executing work.
### Request body
| Field | Type | Required | Description |
|---|---|---|---|
| `idempotency_key` | string | Yes | Unique key for idempotent retries |
| `subject` | Subject | Yes | Budgeting scope |
| `action` | Action | Yes | Action being budgeted |
| `estimate` | Amount | Yes | Estimated cost |
| `ttl_ms` | integer | No | Reservation TTL in ms (default: tenant `default_reservation_ttl_ms` or 60000, range: 1000–86400000, capped to tenant `max_reservation_ttl_ms`) |
| `grace_period_ms` | integer | No | Grace period after TTL for late commits (default: 5000, range: 0–60000) |
| `overage_policy` | string | No | `REJECT`, `ALLOW_IF_AVAILABLE`, or `ALLOW_WITH_OVERDRAFT` (default: tenant `default_commit_overage_policy` or `ALLOW_IF_AVAILABLE`) |
| `dry_run` | boolean | No | If true, evaluate without reserving (default: false) |
| `metadata` | object | No | Arbitrary key-value metadata |
### Response (200 OK)
```json
{
"reservation_id": "res-abc-123",
"decision": "ALLOW",
"expires_at_ms": 1710000060000,
"affected_scopes": [
"tenant:acme",
"tenant:acme/workspace:production"
],
"scope_path": "tenant:acme/workspace:production",
"reserved": { "amount": 5000, "unit": "USD_MICROCENTS" },
"balances": [
{
"scope": "tenant:acme",
"scope_path": "tenant:acme",
"remaining": { "amount": 95000, "unit": "USD_MICROCENTS" },
"allocated": { "amount": 100000, "unit": "USD_MICROCENTS" },
"spent": { "amount": 0, "unit": "USD_MICROCENTS" },
"reserved": { "amount": 5000, "unit": "USD_MICROCENTS" },
"debt": { "amount": 0, "unit": "USD_MICROCENTS" },
"overdraft_limit": { "amount": 0, "unit": "USD_MICROCENTS" },
"is_over_limit": false
}
],
"caps": null,
"reason_code": null,
"retry_after_ms": null
}
```
When `decision` is `ALLOW_WITH_CAPS`, the `caps` field contains soft constraints.
When `decision` is `DENY` (dry_run only), the reservation is not created. For live reservations, insufficient budget returns a `409` error instead of `decision: DENY`.
When `reason_code` is present (on DENY), it provides a machine-readable reason for the denial. `retry_after_ms` optionally suggests when to retry.
### Dry run response
When `dry_run: true`, the response has the same structure but no reservation is persisted. The `reservation_id` and `expires_at_ms` fields are absent. The `affected_scopes` field is always populated, even when the decision is DENY.
### Example
```bash
curl -X POST http://localhost:7878/v1/reservations \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: your-api-key" \
-d '{
"idempotency_key": "req-001",
"subject": {
"tenant": "acme",
"workspace": "production",
"app": "chatbot"
},
"action": {
"kind": "llm.completion",
"name": "gpt-4o"
},
"estimate": {
"amount": 5000,
"unit": "USD_MICROCENTS"
},
"ttl_ms": 60000,
"overage_policy": "REJECT"
}'
```
### Error responses
| Code | Error | When |
|---|---|---|
| 400 | `INVALID_REQUEST` | Missing or invalid fields |
| 400 | `UNIT_MISMATCH` | `estimate.unit` does not match any budget at the derived scopes (a budget exists in a different unit) |
| 401 | `UNAUTHORIZED` | Missing or invalid API key |
| 403 | `FORBIDDEN` | Tenant mismatch |
| 404 | `NOT_FOUND` | No budget ledger exists at any derived scope in any unit (message: `"Budget not found for provided scope: ..."`) |
| 409 | `BUDGET_EXCEEDED` | Insufficient budget |
| 409 | `BUDGET_FROZEN` | Budget scope is frozen |
| 409 | `BUDGET_CLOSED` | Budget scope is permanently closed |
| 409 | `OVERDRAFT_LIMIT_EXCEEDED` | Scope is over-limit |
| 409 | `DEBT_OUTSTANDING` | Scope has unpaid debt (no overdraft limit configured) |
| 409 | `TENANT_CLOSED` | Owning tenant's status is `CLOSED` (persisting create only, `dry_run` absent or `false`; deployments with a governance plane — spec v0.1.25.13, cycles-server 0.1.25.47+) |
| 409 | `IDEMPOTENCY_MISMATCH` | Same key, different payload |
**Dry run:** when `dry_run=true`, budget-state conditions (`BUDGET_EXCEEDED`, `BUDGET_FROZEN`, `BUDGET_CLOSED`, `OVERDRAFT_LIMIT_EXCEEDED`, `DEBT_OUTSTANDING`, `TENANT_CLOSED` on a closed owning tenant, and the 404 "no budget at any scope" case) surface as `200 OK` with `decision: DENY` and a `reason_code` field — `DecisionReasonCode` is an open string (as of v0.1.25); clients MUST handle unknown values gracefully — not as 4xx/409 errors. Request-validity errors (`INVALID_REQUEST`, `UNIT_MISMATCH`, `UNAUTHORIZED`, `FORBIDDEN`, `IDEMPOTENCY_MISMATCH`) are still returned as 4xx on dry-run. See [Decision reason codes](/protocol/error-codes-and-error-handling-in-cycles#decision-reason-codes).
---
## POST /v1/reservations/{id}/commit
Record actual usage and release the unused remainder.
### Request body
| Field | Type | Required | Description |
|---|---|---|---|
| `idempotency_key` | string | Yes | Unique key for idempotent retries |
| `actual` | Amount | Yes | Actual cost consumed |
| `metrics` | object | No | Standard metrics (see below) |
| `metadata` | object | No | Arbitrary audit metadata |
#### Metrics object
```json
{
"tokens_input": 150,
"tokens_output": 80,
"latency_ms": 320,
"model_version": "gpt-4o-2024-08-06",
"custom": { "cache_hit": true }
}
```
### Response (200 OK)
```json
{
"status": "COMMITTED",
"charged": { "amount": 3200, "unit": "USD_MICROCENTS" },
"released": { "amount": 1800, "unit": "USD_MICROCENTS" },
"balances": [
{
"scope": "tenant:acme",
"scope_path": "tenant:acme",
"remaining": { "amount": 96800, "unit": "USD_MICROCENTS" },
"allocated": { "amount": 100000, "unit": "USD_MICROCENTS" },
"spent": { "amount": 3200, "unit": "USD_MICROCENTS" },
"reserved": { "amount": 0, "unit": "USD_MICROCENTS" },
"debt": { "amount": 0, "unit": "USD_MICROCENTS" },
"overdraft_limit": { "amount": 0, "unit": "USD_MICROCENTS" },
"is_over_limit": false
}
]
}
```
### Example
```bash
curl -X POST http://localhost:7878/v1/reservations/res-abc-123/commit \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: your-api-key" \
-d '{
"idempotency_key": "commit-001",
"actual": {
"amount": 3200,
"unit": "USD_MICROCENTS"
},
"metrics": {
"tokens_input": 150,
"tokens_output": 80,
"latency_ms": 320
}
}'
```
### Error responses
| Code | Error | When |
|---|---|---|
| 400 | `UNIT_MISMATCH` | Commit unit differs from reservation unit |
| 401 | `UNAUTHORIZED` | Missing or invalid API key |
| 403 | `FORBIDDEN` | Reservation owned by different tenant |
| 404 | `NOT_FOUND` | Reservation does not exist |
| 409 | `BUDGET_EXCEEDED` | Actual exceeds budget (REJECT only) |
| 409 | `BUDGET_FROZEN` | Budget scope is frozen |
| 409 | `BUDGET_CLOSED` | Budget scope is permanently closed |
| 409 | `OVERDRAFT_LIMIT_EXCEEDED` | Debt would exceed limit (ALLOW_WITH_OVERDRAFT) |
| 409 | `RESERVATION_FINALIZED` | Already committed or released |
| 409 | `TENANT_CLOSED` | Owning tenant's status is `CLOSED` (spec v0.1.25.13, cycles-server 0.1.25.47+); takes precedence over reservation-state errors for non-replay requests |
| 409 | `IDEMPOTENCY_MISMATCH` | Same key, different payload |
| 410 | `RESERVATION_EXPIRED` | TTL + grace period elapsed |
---
## POST /v1/reservations/{id}/release
Cancel a reservation and return all reserved budget to the pool.
### Request body
| Field | Type | Required | Description |
|---|---|---|---|
| `idempotency_key` | string | Yes | Unique key for idempotent retries |
| `reason` | string | No | Human-readable reason for release |
### Response (200 OK)
```json
{
"status": "RELEASED",
"released": { "amount": 5000, "unit": "USD_MICROCENTS" },
"balances": [
{
"scope": "tenant:acme",
"scope_path": "tenant:acme",
"remaining": { "amount": 100000, "unit": "USD_MICROCENTS" },
"allocated": { "amount": 100000, "unit": "USD_MICROCENTS" },
"spent": { "amount": 0, "unit": "USD_MICROCENTS" },
"reserved": { "amount": 0, "unit": "USD_MICROCENTS" },
"debt": { "amount": 0, "unit": "USD_MICROCENTS" },
"overdraft_limit": { "amount": 0, "unit": "USD_MICROCENTS" },
"is_over_limit": false
}
]
}
```
### Example
```bash
curl -X POST http://localhost:7878/v1/reservations/res-abc-123/release \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: your-api-key" \
-d '{
"idempotency_key": "release-001",
"reason": "Task cancelled by user"
}'
```
### Error responses
| Code | Error | When |
|---|---|---|
| 400 | `INVALID_REQUEST` | Malformed request (e.g., missing `idempotency_key`) |
| 401 | `UNAUTHORIZED` | Missing or invalid API key |
| 403 | `FORBIDDEN` | Reservation owned by different tenant |
| 404 | `NOT_FOUND` | Reservation does not exist |
| 409 | `RESERVATION_FINALIZED` | Already committed or released |
| 409 | `TENANT_CLOSED` | Owning tenant's status is `CLOSED` (spec v0.1.25.13, cycles-server 0.1.25.47+); takes precedence over reservation-state errors for non-replay requests |
| 409 | `IDEMPOTENCY_MISMATCH` | Same key, different payload |
| 410 | `RESERVATION_EXPIRED` | TTL + grace period elapsed |
---
## POST /v1/reservations/{id}/extend
Extend the TTL of an active reservation. Used as a heartbeat for long-running operations.
### Request body
| Field | Type | Required | Description |
|---|---|---|---|
| `idempotency_key` | string | Yes | Unique key for idempotent retries |
| `extend_by_ms` | integer | Yes | Milliseconds to extend (range: 1–86400000) |
| `metadata` | object | No | Optional debugging/audit metadata |
### Response (200 OK)
| Field | Type | Required | Description |
|---|---|---|---|
| `status` | string | Yes | Always `"ACTIVE"` after a successful extension |
| `expires_at_ms` | integer (int64) | Yes | New server-authoritative expiry timestamp (ms) |
| `balances` | array of Balance | No | Optional updated balances snapshot after extension |
```json
{
"status": "ACTIVE",
"expires_at_ms": 1710000120000,
"balances": []
}
```
### Example
```bash
curl -X POST http://localhost:7878/v1/reservations/res-abc-123/extend \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: your-api-key" \
-d '{
"idempotency_key": "extend-001",
"extend_by_ms": 60000
}'
```
### Error responses
| Code | Error | When |
|---|---|---|
| 400 | `INVALID_REQUEST` | Missing or invalid fields |
| 401 | `UNAUTHORIZED` | Missing or invalid API key |
| 403 | `FORBIDDEN` | Reservation owned by different tenant |
| 404 | `NOT_FOUND` | Reservation does not exist |
| 409 | `RESERVATION_FINALIZED` | Already committed or released |
| 409 | `TENANT_CLOSED` | Owning tenant's status is `CLOSED` (spec v0.1.25.13, cycles-server 0.1.25.47+); takes precedence over reservation-state errors for non-replay requests |
| 409 | `IDEMPOTENCY_MISMATCH` | Same key, different payload |
| 409 | `MAX_EXTENSIONS_EXCEEDED` | Tenant `max_reservation_extensions` limit reached |
| 410 | `RESERVATION_EXPIRED` | Past TTL (no grace period for extend) |
---
## GET /v1/reservations
List reservations with optional filters and pagination.
### Query parameters
| Parameter | Type | Description |
|---|---|---|
| `tenant` | string | Filter by tenant |
| `workspace` | string | Filter by workspace |
| `app` | string | Filter by app |
| `workflow` | string | Filter by workflow |
| `agent` | string | Filter by agent |
| `toolset` | string | Filter by toolset |
| `status` | string | Filter by status: `ACTIVE`, `COMMITTED`, `RELEASED`, `EXPIRED` |
| `idempotency_key` | string | Filter by idempotency key |
| `from` / `to` | string | ISO 8601 inclusive bounds on `created_at_ms`; either side may be supplied alone |
| `expires_from` / `expires_to` | string | ISO 8601 inclusive bounds on `expires_at_ms`; useful for finding stale or soon-expiring reservations |
| `finalized_from` / `finalized_to` | string | ISO 8601 inclusive bounds on `finalized_at_ms`; only COMMITTED and RELEASED rows match |
| `sort_by` | string | Column to sort by (v0.1.25.12+). See [Sorting](#sorting) below. |
| `sort_dir` | string | `asc` or `desc`. Default `desc`. |
| `include` | string | Comma-separated projection tokens: `metadata`, `committed_metadata`, `evidence` |
| `limit` | integer | Max results (1–200, default: 50) |
| `cursor` | string | Opaque cursor from previous response |
Under `X-Cycles-API-Key`, `tenant` is validation-only and must match the authenticated tenant. Under `X-Admin-API-Key`, `tenant` is required as a filter because admin auth has no effective tenant.
### Sorting
`sort_by` (v0.1.25.12+) accepts one of seven column names:
| Value | Sorts by |
|---|---|
| `reservation_id` | Reservation identifier (lexicographic) |
| `tenant` | Tenant slug |
| `scope_path` | Full scope path |
| `status` | Reservation status enum |
| `reserved` | Held amount (numeric, unit-native) |
| `created_at_ms` | Creation timestamp |
| `expires_at_ms` | Expiry timestamp |
Unknown values return `400 INVALID_REQUEST`. `sort_dir` defaults to `desc`; pass `asc` to reverse.
**Cursor-tuple binding.** The opaque cursor binds to the `(sort_by, sort_dir, filters)` tuple it was issued under. Reusing a cursor with a different sort key, direction, or filter set returns `400 INVALID_REQUEST` — a new first-page request must issue a new cursor. Callers that switch sort mid-walk should discard the cursor and restart.
**Sorted hydration warning.** Current reference servers hydrate all matching rows for sorted reservation listings, then sort and slice. When a sorted query hydrates 2,000 or more rows, the server logs a WARN so operators can add narrower filters or plan sorted indices. Rows beyond 2,000 are no longer truncated in v0.1.25.39+.
### Response (200 OK)
```json
{
"reservations": [
{
"reservation_id": "res-abc-123",
"status": "ACTIVE",
"subject": { "tenant": "acme", "workspace": "production" },
"action": { "kind": "llm.completion", "name": "gpt-4o" },
"reserved": { "amount": 5000, "unit": "USD_MICROCENTS" },
"expires_at_ms": 1710000060000,
"created_at_ms": 1710000000000,
"scope_path": "tenant:acme/workspace:production",
"affected_scopes": ["tenant:acme", "tenant:acme/workspace:production"]
}
],
"has_more": false,
"next_cursor": null
}
```
### Example
```bash
curl -s "http://localhost:7878/v1/reservations?tenant=acme&status=ACTIVE&include=evidence&limit=10" \
-H "X-Cycles-API-Key: your-api-key"
```
### Error responses
| Code | Error | When |
|---|---|---|
| 400 | `INVALID_REQUEST` | Invalid filter parameters |
| 401 | `UNAUTHORIZED` | Missing or invalid API key |
| 403 | `FORBIDDEN` | Tenant mismatch under tenant auth |
---
## GET /v1/reservations/{id}
Get details of a specific reservation.
### Response (200 OK)
```json
{
"reservation_id": "res-abc-123",
"status": "COMMITTED",
"idempotency_key": "req-001",
"subject": { "tenant": "acme", "workspace": "production" },
"action": { "kind": "llm.completion", "name": "gpt-4o" },
"reserved": { "amount": 5000, "unit": "USD_MICROCENTS" },
"committed": { "amount": 3200, "unit": "USD_MICROCENTS" },
"created_at_ms": 1710000000000,
"expires_at_ms": 1710000060000,
"finalized_at_ms": 1710000045000,
"scope_path": "tenant:acme/workspace:production",
"affected_scopes": ["tenant:acme", "tenant:acme/workspace:production"],
"metadata": {},
"committed_metadata": {},
"evidence": {
"reserve": {
"evidence_id": "8403bed43e13ef7d56a8ab402a9d29ee7dd2f405e24c0cacb51068341a5e7030",
"cycles_evidence_url": "https://cycles.example.com/v1/evidence/8403bed43e13ef7d56a8ab402a9d29ee7dd2f405e24c0cacb51068341a5e7030"
}
}
}
```
### Example
```bash
curl -s http://localhost:7878/v1/reservations/res-abc-123 \
-H "X-Cycles-API-Key: your-api-key"
```
### Error responses
| Code | Error | When |
|---|---|---|
| 401 | `UNAUTHORIZED` | Missing or invalid API key |
| 403 | `FORBIDDEN` | Reservation owned by different tenant |
| 404 | `NOT_FOUND` | Reservation does not exist |
| 410 | `RESERVATION_EXPIRED` | Reservation has expired |
---
## POST /v1/decide
Evaluate a budget decision without creating a reservation. Useful for preflight checks, UI affordances, and routing decisions.
### Request body
| Field | Type | Required | Description |
|---|---|---|---|
| `idempotency_key` | string | Yes | Unique key for idempotent retries |
| `subject` | Subject | Yes | Budgeting scope |
| `action` | Action | Yes | Action being evaluated |
| `estimate` | Amount | Yes | Estimated cost to evaluate |
| `metadata` | object | No | Arbitrary metadata |
### Response (200 OK)
```json
{
"decision": "ALLOW",
"affected_scopes": [
"tenant:acme",
"tenant:acme/workspace:production"
]
}
```
The `reason_code` and `retry_after_ms` fields are present when the decision is `DENY`. `reason_code` is `DecisionReasonCode` — an open string (as of v0.1.25) with seven documented known values: `BUDGET_EXCEEDED`, `BUDGET_FROZEN`, `BUDGET_CLOSED`, `BUDGET_NOT_FOUND`, `OVERDRAFT_LIMIT_EXCEEDED`, `DEBT_OUTSTANDING`, `TENANT_CLOSED` (added in spec v0.1.25.13 for closed owning tenants). Clients MUST handle unknown values gracefully. See [Decision reason codes](/protocol/error-codes-and-error-handling-in-cycles#decision-reason-codes).
### Example
```bash
curl -X POST http://localhost:7878/v1/decide \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: your-api-key" \
-d '{
"idempotency_key": "decide-001",
"subject": { "tenant": "acme", "workspace": "production" },
"action": { "kind": "llm.completion", "name": "gpt-4o" },
"estimate": { "amount": 5000, "unit": "USD_MICROCENTS" }
}'
```
### Error responses
| Code | Error | When |
|---|---|---|
| 400 | `INVALID_REQUEST` | Missing or invalid fields |
| 400 | `UNIT_MISMATCH` | `estimate.unit` does not match any budget at the derived scopes (a budget exists in a different unit) |
| 401 | `UNAUTHORIZED` | Missing or invalid API key |
| 403 | `FORBIDDEN` | Tenant mismatch |
| 409 | `IDEMPOTENCY_MISMATCH` | Same key, different payload |
Note: decide returns `200` with `decision: DENY` for all budget-state conditions — insufficient remaining, debt, overdraft, frozen, closed, the "no budget exists at any scope" case, and a closed owning tenant — not a `409` or `404`. The specific reason is surfaced in the `reason_code` field. `DecisionReasonCode` is an open string (as of v0.1.25) with seven documented known values: `BUDGET_EXCEEDED`, `BUDGET_FROZEN`, `BUDGET_CLOSED`, `BUDGET_NOT_FOUND`, `OVERDRAFT_LIMIT_EXCEEDED`, `DEBT_OUTSTANDING`, `TENANT_CLOSED` (spec v0.1.25.13; fresh evaluations on a closed owning tenant — never `409 TENANT_CLOSED` on this endpoint, though a present-but-malformed tenant record fails closed with `500 INTERNAL_ERROR`). Clients MUST handle unknown values gracefully. See [Decision reason codes](/protocol/error-codes-and-error-handling-in-cycles#decision-reason-codes) for full semantics. Request-validity errors like `UNIT_MISMATCH` are still returned as `400`.
---
## GET /v1/balances
Query current budget state for one or more scopes.
### Query parameters
| Parameter | Type | Description |
|---|---|---|
| `tenant` | string | Filter by tenant |
| `workspace` | string | Filter by workspace |
| `app` | string | Filter by app |
| `workflow` | string | Filter by workflow |
| `agent` | string | Filter by agent |
| `toolset` | string | Filter by toolset |
| `include_children` | boolean | Include child scopes (default: false) |
| `limit` | integer | Max results (1–200, default: 50) |
| `cursor` | string | Opaque cursor from previous response |
At least one of `tenant`, `workspace`, `app`, `workflow`, `agent`, or `toolset` must be provided. The `tenant` parameter is validation-only: if provided, it must match the effective tenant derived from the API key.
### Response (200 OK)
```json
{
"balances": [
{
"scope": "tenant:acme",
"scope_path": "tenant:acme",
"remaining": { "amount": 96800, "unit": "USD_MICROCENTS" },
"allocated": { "amount": 100000, "unit": "USD_MICROCENTS" },
"spent": { "amount": 3200, "unit": "USD_MICROCENTS" },
"reserved": { "amount": 0, "unit": "USD_MICROCENTS" },
"debt": { "amount": 0, "unit": "USD_MICROCENTS" },
"overdraft_limit": { "amount": 0, "unit": "USD_MICROCENTS" },
"is_over_limit": false
},
{
"scope": "workspace:production",
"scope_path": "tenant:acme/workspace:production",
"remaining": { "amount": 46800, "unit": "USD_MICROCENTS" },
"allocated": { "amount": 50000, "unit": "USD_MICROCENTS" },
"spent": { "amount": 3200, "unit": "USD_MICROCENTS" },
"reserved": { "amount": 0, "unit": "USD_MICROCENTS" },
"debt": { "amount": 0, "unit": "USD_MICROCENTS" },
"overdraft_limit": { "amount": 0, "unit": "USD_MICROCENTS" },
"is_over_limit": false
}
],
"has_more": false,
"next_cursor": null
}
```
### Example
```bash
curl -s "http://localhost:7878/v1/balances?tenant=acme&workspace=production" \
-H "X-Cycles-API-Key: your-api-key"
```
### Error responses
| Code | Error | When |
|---|---|---|
| 400 | `INVALID_REQUEST` | No subject filter provided |
| 401 | `UNAUTHORIZED` | Missing or invalid API key |
| 403 | `FORBIDDEN` | Tenant mismatch |
---
## POST /v1/events
Record a direct debit event without a prior reservation. Used for post-hoc accounting when the reserve → commit lifecycle does not apply.
### Request body
| Field | Type | Required | Description |
|---|---|---|---|
| `idempotency_key` | string | Yes | Unique key for idempotent retries |
| `subject` | Subject | Yes | Budgeting scope |
| `action` | Action | Yes | Action being recorded |
| `actual` | Amount | Yes | Actual cost to record |
| `overage_policy` | string | No | `REJECT`, `ALLOW_IF_AVAILABLE`, or `ALLOW_WITH_OVERDRAFT` (default: tenant `default_commit_overage_policy` or `ALLOW_IF_AVAILABLE`) |
| `metrics` | object | No | Standard metrics |
| `client_time_ms` | integer | No | Client-side timestamp |
| `metadata` | object | No | Arbitrary metadata |
### Response (201 Created)
```json
{
"status": "APPLIED",
"event_id": "evt-abc-123",
"charged": { "amount": 4400, "unit": "USD_MICROCENTS" },
"balances": [
{
"scope": "tenant:acme",
"scope_path": "tenant:acme",
"remaining": { "amount": 95600, "unit": "USD_MICROCENTS" },
"allocated": { "amount": 100000, "unit": "USD_MICROCENTS" },
"spent": { "amount": 4400, "unit": "USD_MICROCENTS" },
"reserved": { "amount": 0, "unit": "USD_MICROCENTS" },
"debt": { "amount": 0, "unit": "USD_MICROCENTS" },
"overdraft_limit": { "amount": 0, "unit": "USD_MICROCENTS" },
"is_over_limit": false
}
]
}
```
`charged` is optional. It is present when `overage_policy` is `ALLOW_IF_AVAILABLE` and the actual amount was capped to the remaining budget, so the client can see the effective charge applied.
### Example
```bash
curl -X POST http://localhost:7878/v1/events \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: your-api-key" \
-d '{
"idempotency_key": "evt-001",
"subject": {
"tenant": "acme",
"workspace": "production"
},
"action": {
"kind": "search.api",
"name": "google-search"
},
"actual": {
"amount": 1200,
"unit": "USD_MICROCENTS"
}
}'
```
### Error responses
| Code | Error | When |
|---|---|---|
| 400 | `INVALID_REQUEST` | Missing or invalid fields |
| 400 | `UNIT_MISMATCH` | `actual.unit` does not match any budget at the target scope (a budget exists in a different unit) |
| 401 | `UNAUTHORIZED` | Missing or invalid API key |
| 403 | `FORBIDDEN` | Tenant mismatch |
| 404 | `NOT_FOUND` | No budget ledger exists at any derived scope in any unit (message: `"Budget not found for provided scope: ..."`) |
| 409 | `BUDGET_EXCEEDED` | Insufficient budget (REJECT only) |
| 409 | `BUDGET_FROZEN` | Budget scope is frozen |
| 409 | `BUDGET_CLOSED` | Budget scope is permanently closed |
| 409 | `OVERDRAFT_LIMIT_EXCEEDED` | Debt would exceed limit |
| 409 | `IDEMPOTENCY_MISMATCH` | Same key, different payload |
---
## GET /v1/evidence/{evidence_id}
Fetch a signed CyclesEvidence envelope by content id. This endpoint is public: the `evidence_id` is a 64-character lowercase SHA-256 content hash carried by a prior `cycles_evidence` response reference, and the returned envelope is content-addressed and signature-verifiable.
### Path parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| `evidence_id` | string | Yes | 64 lowercase hex characters; SHA-256 content hash of the evidence envelope |
### Response (200 OK)
Returns the signed envelope as JSON. The exact envelope shape is covered in [CyclesEvidence Envelopes](/protocol/cycles-evidence-envelopes-in-cycles).
```json
{
"schema_version": "cycles-evidence/v0.1",
"artifact_type": "reserve",
"server_id": "https://cycles.example.com/v1",
"signer_did": "b10554...",
"issued_at_ms": 1781436904050,
"trace_id": "b2a0ab88...",
"payload": { "reserve": { "request": {}, "response": {} } },
"evidence_id": "8403bed43e13ef7d56a8ab402a9d29ee7dd2f405e24c0cacb51068341a5e7030",
"signature": "4bc8cb9a..."
}
```
### Example
```bash
curl -s http://localhost:7878/v1/evidence/8403bed43e13ef7d56a8ab402a9d29ee7dd2f405e24c0cacb51068341a5e7030
```
### Error responses
| Code | Error | When |
|---|---|---|
| 400 | `INVALID_REQUEST` | `evidence_id` is not a valid 64-character lowercase hex string |
| 404 | `NOT_FOUND` | Envelope is not available or evidence signing/storage is not configured |
| 429 | `LIMIT_EXCEEDED` | Public endpoint throttled (reference server default: 300 requests/minute per client IP); retry after `Retry-After` |
---
## GET /v1/.well-known/cycles-jwks.json
Fetch the issuing server's public CyclesEvidence JWK Set. This endpoint is public and contains verification keys only; the private signing key is never served.
The path is API-base-relative. If `server_id` is `https://cycles.example.com/v1`, the JWKS URL is `https://cycles.example.com/v1/.well-known/cycles-jwks.json`.
### Response (200 OK)
```json
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"alg": "EdDSA",
"x": "base64url-public-key",
"kid": "2026-h2",
"cycles_nbf_ms": 1781000000000,
"status": "active"
}
]
}
```
### Example
```bash
curl -s http://localhost:7878/v1/.well-known/cycles-jwks.json
```
### Error responses
| Code | Error | When |
|---|---|---|
| 404 | `NOT_FOUND` | The server does not publish a JWK Set, usually because signer-key resolution is not configured |
| 429 | `LIMIT_EXCEEDED` | Public endpoint throttled (reference server default: 300 requests/minute per client IP); retry after `Retry-After` |
---
## Idempotency
All write operations require idempotency via the `idempotency_key` field in the request body. The `X-Idempotency-Key` header is also accepted; if both are provided, they must match.
- If you retry a request with the same key and the same payload, you get the original successful response. The operation is not applied again.
- If you reuse a key with a different payload, you get `409 IDEMPOTENCY_MISMATCH`.
- If the original request failed, retrying with the same key sends a fresh request.
Idempotency is scoped per (effective tenant, endpoint, idempotency_key).
## Next steps
- [Error Codes and Error Handling](/protocol/error-codes-and-error-handling-in-cycles) — detailed error code reference
- [Self-Hosting the Cycles Server](/quickstart/self-hosting-the-cycles-server) — deploy your own instance
- [Getting Started with the Spring Boot Starter](/quickstart/getting-started-with-the-cycles-spring-boot-starter) — client integration
# Authentication, Tenancy, and API Keys in Cycles
Every protected budget operation is authenticated and tenant-scoped. Liveness/readiness and other explicitly public operational or evidence endpoints are exceptions.
These two properties — authentication and tenancy — are foundational. They determine who is making the request, which budgets are visible, and which reservations can be accessed.
## How authentication works
Cycles authenticates requests using the `X-Cycles-API-Key` header.
Tenant-authenticated runtime requests must include this header. If it is missing or the key is invalid, the server returns `401 UNAUTHORIZED`. Admin and public endpoints follow their own authentication rules.
```
X-Cycles-API-Key: your-api-key
```
There is no session, token exchange, or OAuth flow in the reference API. Protected tenant requests use one API-key header; admin-only routes use `X-Admin-API-Key`.
### Public endpoints (no API key)
The runtime YAML declares two public evidence endpoints with `security: []`:
- `GET /v1/evidence/{evidence_id}` — signed-envelope retrieval. The `evidence_id` is an unguessable content-hash capability, and the envelope is content-addressed and signed, so public read cannot forge or alter it.
- `GET /v1/.well-known/cycles-jwks.json` — the signer's public JWK Set (public keys only, the standard posture for a verification key set), used to verify evidence signatures.
The reference server also leaves `/actuator/health/liveness` and `/actuator/health/readiness` public for probes. Aggregate health, metrics, API documentation, and the remaining operational/API routes require the configured tenant or admin credential.
## The effective tenant
From the API key (or other auth context), the server determines an **effective tenant**.
The effective tenant is the identity that governs all budget operations for that request:
- which budgets can be queried
- which reservations can be created
- which reservations can be committed, released, or extended
- which balances are visible
The effective tenant is not sent by the client. It is derived by the server from the authentication context.
## Tenant validation on every request
Every request that includes a `Subject` must have a `tenant` field that matches the effective tenant.
If the client sends `subject.tenant = "acme"` but the API key maps to tenant `"beta"`, the server returns `403 FORBIDDEN`.
This is a normative rule: the server **must** reject any request where `subject.tenant` does not match the effective tenant.
This prevents one tenant from creating reservations, recording events, or querying budgets against another tenant's scopes.
## Reservation ownership
Every reservation is bound to the effective tenant at creation time.
Any subsequent operation on that reservation — commit, release, extend, or get — must come from the same effective tenant.
If a different tenant attempts to access the reservation, the server returns `403 FORBIDDEN`, even if the reservation ID is known.
This means:
- tenant A cannot commit tenant B's reservation
- tenant A cannot release tenant B's reservation
- tenant A cannot extend tenant B's reservation
- tenant A cannot view tenant B's reservation details
Reservation ownership is enforced at the protocol level, not by convention.
## Balance visibility
Balance queries are tenant-scoped.
The server only returns balances within the effective tenant's scope. If the `tenant` query parameter is provided, it is validation-only — it must match the effective tenant, or the server returns `403 FORBIDDEN`.
If the `tenant` parameter is omitted, the effective tenant is used automatically.
A tenant cannot query another tenant's balances under any circumstances.
## Tenant validation on listing endpoints
The reservation listing endpoint (`GET /v1/reservations`) follows the same tenancy rules:
- results are scoped to the effective tenant
- if the `tenant` query parameter is provided, it must match the effective tenant
- if it does not match, the server returns `403 FORBIDDEN`
This ensures that listing and recovery operations are always tenant-isolated.
## Admin access on behalf of a tenant
Three runtime reservation endpoints also accept the admin key header `X-Admin-API-Key` (the `AdminKeyAuth` scheme — the same header the governance-admin spec uses), so admin operators can authenticate against the runtime plane with one key:
- `GET /v1/reservations` (list) — the admin caller has no effective tenant, so the `tenant` query parameter is **required** and is used as a filter, not validation. Omitting it returns `400 INVALID_REQUEST`.
- `GET /v1/reservations/{reservation_id}` (get) — admin operators can read any reservation regardless of owning tenant; the reservation ID already pins the owner, so no extra parameter is needed.
- `POST /v1/reservations/{reservation_id}/release` — admin operators can release any reservation regardless of owning tenant (the ops use case is force-expiring a hung reservation during incident response). The audit-log entry for an admin-driven release must record `actor_type=admin_on_behalf_of`, so security review can distinguish admin-driven releases from tenant self-service releases.
Create, commit, and extend do not accept the admin key — they remain tenant-key-only operations.
## The decide endpoint and tenancy
The decide endpoint (`POST /v1/decide`) follows the same rule: `subject.tenant` must match the effective tenant.
Even though decide is a read-only preflight check that does not modify budget state, it still enforces tenant isolation.
## How the X-Cycles-Tenant response header works
The server may include an `X-Cycles-Tenant` response header on any response.
This header contains the effective tenant identifier derived from the authentication context.
It is useful for:
- debugging tenant mismatch errors
- confirming which tenant the server resolved from the API key
- logging and correlation in multi-tenant environments
This header is optional in v0 implementations.
## API key permissions (governance layer)
When using the Cycles governance server (admin API), API keys can carry granular permissions that restrict which operations they can perform. The governance spec's `Permission` enum defines 27 values in three groups.
### Tenant permissions (13)
Tenant-scoped keys should carry only these permissions:
| Permission | Operations |
|---|---|
| `reservations:create` | Create reservations |
| `reservations:commit` | Commit reservations |
| `reservations:release` | Release reservations |
| `reservations:extend` | Extend reservations |
| `reservations:list` | List and get reservations |
| `balances:read` | Query balances |
| `budgets:read` | List and read budgets |
| `budgets:write` | Create, update, and fund budgets |
| `policies:read` | List and read policies |
| `policies:write` | Create and update policies |
| `webhooks:read` | Read webhook subscriptions and deliveries (self-service) |
| `webhooks:write` | Create, update, and delete webhook subscriptions (self-service) |
| `events:read` | Read the tenant's event stream |
### Legacy wildcard admin permissions (2)
`admin:read` and `admin:write` are reserved for backward compatibility with legacy keys and SHOULD NOT be assigned to new tenant keys — use the granular tenant permissions above instead. Their wildcard semantics are normative:
- `admin:write` satisfies any `*:write` requirement (`budgets:write`, `policies:write`, `webhooks:write`, etc.)
- `admin:read` satisfies any `*:read` requirement (`budgets:read`, `policies:read`, `events:read`, `balances:read`, etc.)
- `admin:read` does **not** satisfy `*:write`
### Granular admin permissions (12)
Admin-plane operations use granular admin permissions rather than the legacy wildcards: `admin:tenants:read`, `admin:tenants:write`, `admin:budgets:read`, `admin:budgets:write`, `admin:policies:read`, `admin:policies:write`, `admin:apikeys:read`, `admin:apikeys:write`, `admin:webhooks:read`, `admin:webhooks:write`, `admin:events:read`, and `admin:audit:read`.
### Default permission set
When `permissions` is omitted from the API key creation request, the server assigns the default set for tenant keys — 10 permissions: `[reservations:create, reservations:commit, reservations:release, reservations:extend, reservations:list, balances:read, budgets:read, budgets:write, policies:read, policies:write]`.
Only the webhook and event self-service permissions (`webhooks:read`, `webhooks:write`, `events:read`) are excluded from the default set and must be explicitly granted.
If a request requires a permission the API key does not have, the governance server returns `403` with error code `INSUFFICIENT_PERMISSIONS` (defined in the governance spec, not the protocol spec).
### Key prefixes
Tenant keys are issued in the format `cyc_live_{random}` or `cyc_test_{random}`. Only the visible `key_prefix` (e.g., `cyc_live_abc123`) is returned for identification after creation — the full key secret is returned exactly once, at creation time, and is stored server-side only as a hash.
## Scope filter (governance layer)
API keys can optionally be restricted to specific scopes using a **scope filter**.
A scope filter is an array of scope segment patterns:
```json
"scope_filter": ["workspace:eng", "agent:*"]
```
When a scope filter is set on an API key:
- operations are only permitted on scopes containing a matching segment
- `"workspace:eng"` matches scopes like `tenant:acme/workspace:eng/agent:bot1` (exact segment match)
- `"agent:*"` matches any scope containing an agent segment (wildcard match)
- if no filter entry matches the requested scope, the server returns `403 FORBIDDEN`
When scope filter is empty or not set, the key has unrestricted access within its tenant.
## Practical implications
### One API key per tenant (typical)
Most deployments map each API key to exactly one tenant. This makes tenant validation automatic — the client sets `subject.tenant` to match its key, and all operations are scoped correctly.
### Multi-tenant API keys (advanced)
Some deployments may use API keys that can operate on behalf of multiple tenants. In this case, the effective tenant derivation logic is implementation-specific and may involve additional headers or request context.
The protocol does not define how effective tenant is derived — only that it must be derived and enforced consistently.
### Tenant mismatch debugging
When a `403 FORBIDDEN` error occurs, the most common cause is a tenant mismatch:
- the `subject.tenant` in the request does not match the effective tenant from the API key
- a commit or release targets a reservation owned by a different tenant
- a balance query specifies a tenant that does not match the API key
Check the `X-Cycles-Tenant` response header (if present) to see which tenant the server resolved.
## Security properties
The authentication and tenancy model provides several guarantees:
- **Isolation**: tenants cannot see or modify each other's budgets, reservations, or balances
- **Ownership**: reservations are permanently bound to the creating tenant
- **Validation**: every protected tenant operation is checked against the effective tenant before processing
- **Consistency**: the same tenancy rules apply to all endpoints (reserve, commit, release, extend, decide, events, balances, listing)
These properties hold regardless of whether the client is trusted. The server enforces them on protected tenant operations.
## Summary
Tenant-plane authentication uses the `X-Cycles-API-Key` header. Admin-only routes use `X-Admin-API-Key`, and explicitly public routes require neither.
The server derives an effective tenant from the key and enforces tenant isolation across all operations:
- **Subject.tenant** must match the effective tenant on operations that carry a `Subject`; tenant query parameters are validation-only where supported
- **Reservation ownership** is enforced on commit, release, extend, and get
- **Balance visibility** is scoped to the effective tenant
- **403 FORBIDDEN** is returned for any tenant mismatch
This ensures that budget governance is always tenant-isolated, even in shared deployments.
## Next steps
- [Tenant Creation and Management](/how-to/tenant-creation-and-management-in-cycles) — create, configure, and manage tenants via the Admin API
- [API Key Management](/how-to/api-key-management-in-cycles) — create and rotate tenant-scoped API keys
- [Budget Allocation and Management](/how-to/budget-allocation-and-management-in-cycles) — set up budgets at tenant and sub-scopes
- [Scope Derivation](/protocol/how-scope-derivation-works-in-cycles) — how tenant scopes fit into the budget hierarchy
- [Deploy the Full Stack](/quickstart/deploying-the-full-cycles-stack) — set up the Cycles infrastructure from scratch
- Integrate with [Python](/quickstart/getting-started-with-the-python-client), [TypeScript](/quickstart/getting-started-with-the-typescript-client), or [Spring AI](/how-to/integrating-cycles-with-spring-ai)
# Caps and the Three-Way Decision Model in Cycles
Most budget systems make a binary decision: allow or deny.
Cycles adds a third option.
When a reservation or decide request is evaluated, the server returns one of three decisions:
- **ALLOW** — sufficient budget exists, proceed normally
- **ALLOW_WITH_CAPS** — sufficient budget exists, but soft constraints apply
- **DENY** — insufficient budget or policy block
The middle option — ALLOW_WITH_CAPS — is what makes Cycles more useful than a simple gate.
## Where DENY appears
One wire-level detail matters here: `decision: DENY` only ever appears on responses that do not hold budget — `POST /v1/decide` responses and dry-run (`dry_run: true`) reservation responses.
A live (non-dry-run) reservation never returns `decision: DENY`. When budget is unavailable, the server rejects the request with an HTTP `409` error — `BUDGET_EXCEEDED`, or another 409 code such as `OVERDRAFT_LIMIT_EXCEEDED`, `DEBT_OUTSTANDING`, or `TENANT_CLOSED` — instead of a 200 response carrying a DENY decision.
Where callers find the denial reason follows the same split. On a DENY decision (decide or dry run), the response's `reason_code` field carries the machine-readable reason — `BUDGET_EXCEEDED`, `BUDGET_FROZEN`, `BUDGET_CLOSED`, `BUDGET_NOT_FOUND`, `OVERDRAFT_LIMIT_EXCEEDED`, `DEBT_OUTSTANDING`, or `TENANT_CLOSED`. On a live denial, the equivalent information is in the 409 error response's `error` field. See [Decision reason codes](/protocol/error-codes-and-error-handling-in-cycles#decision-reason-codes).
## Why binary decisions are not enough
In practice, many autonomous actions can still produce value in a constrained mode.
A model call does not have to use the maximum token budget.
An agent does not have to invoke every available tool.
A workflow does not have to run at full concurrency.
If the only options are "full speed" or "stop," teams end up choosing one extreme:
- budgets are set too loose (to avoid breaking production)
- or budgets are set too tight (and useful work gets denied unnecessarily)
Caps provide a middle ground.
## What caps are
Caps are operator-configured constraints that the server returns from the deepest matching budget when the decision is ALLOW_WITH_CAPS.
The protocol defines five cap fields:
### max_tokens
The maximum number of tokens the action should consume.
This lets the server say: "you may proceed, but limit output length."
### max_steps_remaining
The maximum number of remaining steps for an agent or loop.
This lets the server say: "you may proceed, but wrap up soon."
### tool_allowlist
A list of tools the action is allowed to use.
If present, only these tools may be invoked. All others are implicitly denied.
### tool_denylist
A list of tools the action is not allowed to use.
If present, all tools except these may be invoked.
The allowlist takes precedence: if `tool_allowlist` is non-empty, the denylist is ignored.
### cooldown_ms
A delay in milliseconds before the next action should begin.
This lets the server throttle execution without denying it outright.
## How caps flow through the system
Caps are returned by the server in two places:
1. **Reservation responses** — when the decision on `POST /v1/reservations` is ALLOW_WITH_CAPS
2. **Decide responses** — when the decision on `POST /v1/decide` is ALLOW_WITH_CAPS
Caps are only present when the decision is ALLOW_WITH_CAPS. They are absent for ALLOW and DENY.
The client is responsible for respecting caps. The server does not enforce them at commit time. They are guidance, not hard blocks.
## When caps appear
In the current server, caps come from the deepest matching budget that has an operator-configured `caps` object. Their presence is configuration-driven, not an automatic response to utilization or remaining balance. A deployment that wants progressive narrowing must change which configured policy or scope applies; the base server does not tighten caps as spend rises.
## Using caps in practice
### Model calls
When `max_tokens` is returned, the client should pass it to the model provider as a generation limit.
This reduces the cost of the call without denying it entirely.
### Agent loops
When `max_steps_remaining` is returned, the agent should plan to finish within that many steps.
This creates a bounded wind-down instead of an abrupt stop.
### Tool selection
When `tool_allowlist` or `tool_denylist` is returned, the agent should filter its available tools accordingly.
This narrows the action surface when the configured policy calls for it without eliminating all capability.
### Pacing
When `cooldown_ms` is returned, the client should wait that long before making the next action.
This reduces execution velocity without stopping the workflow.
## Caps in client code
Inside a `@cycles`-decorated function (Python) or `@Cycles`-annotated method (Java), access caps through the context object:
::: code-group
```python [Python]
from runcycles import cycles, get_cycles_context
@cycles(estimate=1000)
def process(prompt: str) -> str:
ctx = get_cycles_context()
if ctx.has_caps():
if ctx.caps.max_tokens:
# limit generation length
pass
if not ctx.caps.is_tool_allowed("web.search"):
# skip web search tool
pass
return call_llm(prompt)
```
```java [Java (Spring Boot)]
CyclesReservationContext ctx = CyclesContextHolder.get();
if (ctx.hasCaps()) {
Caps caps = ctx.getCaps();
if (caps.getMaxTokens() != null) {
// limit generation length
}
if (!caps.isToolAllowed("web.search")) {
// skip web search tool
}
}
```
:::
The `@Cycles` annotation logs a warning when ALLOW_WITH_CAPS is returned, so teams can see when caps are being applied even without explicit handling.
## Caps are advisory
Caps are not enforced by the server at commit time.
If the client ignores `max_tokens` and generates more output, the commit will still succeed (subject to the overage policy).
But respecting caps is important for two reasons:
1. It keeps budget consumption aligned with server expectations
2. Lower-cost execution can preserve remaining budget and delay a later hard denial
Caps are configuration supplied with an accepted decision. They are only effective when the caller or a mandatory host boundary applies them.
## Tool list precedence
The tool filtering logic follows a clear precedence:
1. If `tool_allowlist` is non-empty, only those tools are allowed (denylist is ignored)
2. If `tool_allowlist` is empty but `tool_denylist` is non-empty, all tools except those in the denylist are allowed
3. If both are empty or absent, no tool restrictions apply
Tool names are case-sensitive and match the `Action.name` field exactly.
## How caps relate to degradation paths
Caps are the protocol-level mechanism behind the degradation strategies described in the Cycles documentation:
- **Downgrade** — reduce model size or generation length → `max_tokens`
- **Disable** — remove expensive tools → `tool_allowlist` / `tool_denylist`
- **Defer** — slow down execution → `cooldown_ms`
- **Limit scope** — bound remaining steps → `max_steps_remaining`
Caps make these degradation paths concrete and server-driven rather than hardcoded in the client.
## Summary
The three-way decision model — ALLOW, ALLOW_WITH_CAPS, DENY — gives Cycles a richer control surface than binary allow/deny.
Caps provide server-driven guidance that lets clients:
- reduce token consumption
- limit remaining steps
- restrict tool usage
- pace execution
This supports a configured degradation path between full execution and hard denial.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
# Commit Overage Policies in Cycles: REJECT, ALLOW_IF_AVAILABLE, and ALLOW_WITH_OVERDRAFT
::: warning Version note
In v0.1.24, the hardcoded fallback default changed from `REJECT` to `ALLOW_IF_AVAILABLE`. If you are running a server version prior to v0.1.24 and do not set an explicit overage policy on your requests or tenant configuration, the server defaults to `REJECT`. See the [changelog](/changelog) for details.
:::
When actual usage differs from the reserved estimate, the system needs a policy for what to do.
If actual is less than reserved, the unused remainder is released automatically. That is straightforward.
But if actual is more than reserved, the system has a decision to make.
That is what commit overage policies control.
## Quick reference: which policy to use
| Policy | Best for | Commits rejected? | Creates debt? | Tradeoff |
|---|---|---|---|---|
| **REJECT** | Well-estimated actions where hard budget stops are acceptable | Yes, if actual > reserved | No | Strictest enforcement, but rejected commits leave unaccounted gaps |
| **ALLOW_IF_AVAILABLE** (default) | Most workloads — variable-cost actions, LLM calls, tool invocations | Never | No | Always records usage, caps charge to available budget, blocks future reservations when exhausted |
| **ALLOW_WITH_OVERDRAFT** | Must-record actions — external imports, side-effecting operations, SLA-critical work | Only if debt would exceed overdraft limit | Yes | Most accurate ledger, requires operator debt reconciliation |
**Common patterns:**
- **LLM completions with unpredictable token counts** → ALLOW_IF_AVAILABLE
- **Tool calls with known fixed costs** → REJECT (with buffer) or ALLOW_IF_AVAILABLE
- **External API usage imports** → ALLOW_WITH_OVERDRAFT (work already happened)
- **Multi-tenant platform default** → ALLOW_IF_AVAILABLE (safe default, no debt)
- **Strict per-agent budget caps** → REJECT (hard stop when budget is gone)
For a scenario-driven guide, see [Choosing the Right Overage Policy](/how-to/choosing-the-right-overage-policy).
## The three policies
Cycles defines three overage policies, set at reservation time (or on events):
- **REJECT** — refuse the commit if actual exceeds reserved
- **ALLOW_IF_AVAILABLE** — allow if remaining budget can cover the difference
- **ALLOW_WITH_OVERDRAFT** — allow and create debt if necessary
Each policy makes a different tradeoff between ledger accuracy and budget strictness.
### Resolution order
When a reservation or event is created, the server resolves the overage policy in this order:
1. **Request-level** `overage_policy` — if the client specifies one, it is used
2. **Tenant default** `default_commit_overage_policy` — if the tenant has one configured via the Admin API
3. **Hardcoded fallback** — `ALLOW_IF_AVAILABLE`
This means tenant administrators can set an org-wide default (e.g. `REJECT`) and individual requests can still override it.
### Step 0: the commit must be valid
Overage policies only come into play after the commit passes basic validation. Before any policy branch is evaluated, the server checks that:
1. The reservation exists and is owned by the effective tenant — otherwise `404 NOT_FOUND` or `403 FORBIDDEN`
2. The reservation is still committable — not already `COMMITTED` or `RELEASED` (`409 RESERVATION_FINALIZED`) and not past `expires_at_ms + grace_period_ms` (`410 RESERVATION_EXPIRED`)
3. The `actual.unit` matches the reservation's `estimate.unit` — otherwise `400 UNIT_MISMATCH`
A commit that fails any of these checks is rejected regardless of overage policy. Even ALLOW_IF_AVAILABLE, which never rejects a commit on budget grounds, does not accept an invalid commit.
## REJECT
REJECT is the strictest overage policy. Tenant administrators can set it as the default for all reservations and events in their tenant by setting `default_commit_overage_policy` via the Admin API.
REJECT is the simplest and strictest policy.
If `actual > reserved`, the commit is rejected with `409 BUDGET_EXCEEDED`.
### When REJECT is right
- the system can predict costs well enough to reserve sufficient room
- hard budget enforcement is more important than ledger completeness
- a 10–20% buffer on estimates is acceptable
- rejected commits will be retried with a new reservation
### The risk with REJECT
If the action already happened — the model call returned, the tool executed — rejecting the commit creates an unaccounted gap.
The work occurred but is not recorded in the budget ledger. Budget appears more available than it really is.
This is why the spec recommends adding a 10–20% buffer to estimates when using REJECT.
### Practical guidance
- Use REJECT when estimates are reliable
- Add estimation buffers to avoid frequent rejections
- Monitor how often commits are rejected — high rejection rates signal estimation problems
## ALLOW_IF_AVAILABLE (default)
ALLOW_IF_AVAILABLE is the default overage policy when neither the request nor the tenant configuration specifies one. It ensures commits always succeed — the action already happened, so the ledger must reflect it as accurately as possible.
The server checks whether remaining budget across all affected scopes can cover the full delta between actual and reserved.
If yes: the commit succeeds and the full delta is charged atomically.
If no: the commit still succeeds, but the delta is **capped** to the minimum available remaining across all affected scopes (floor 0). The charge is `estimate + capped_delta`. Scopes where the full delta could not be covered are marked `is_over_limit=true`, blocking future reservations until reconciled.
### When ALLOW_IF_AVAILABLE is right
- the system wants to allow modest overages when budget permits
- strict estimation is difficult
- the system should never create debt
- budget accuracy matters but some flexibility is acceptable
- commits should never be rejected after the action has happened
### How it works
**Full delta available:** Suppose a reservation held 100 units and actual usage was 130 units. The delta is 30 units. The server checks whether all affected scopes have at least 30 units of remaining budget. If yes: commit succeeds, 130 is charged.
**Capped delta:** Suppose budget remaining is 200, estimate is 200, actual is 201. The delta is 1 unit. After reservation, remaining is 0. The server caps the delta to 0 (nothing available). Charge is `200 + 0 = 200`. The scope is marked `is_over_limit=true`, blocking future reservations. Budget after: remaining=0, spent includes 200, no debt.
### The key properties
ALLOW_IF_AVAILABLE never creates debt. It never rejects a commit. It charges the maximum amount possible without creating debt, and blocks future reservations when the full overage could not be covered.
This makes it a safe default — work is always accounted for, and the system self-limits when budget is exhausted.
## ALLOW_WITH_OVERDRAFT
ALLOW_WITH_OVERDRAFT allows the commit to succeed even when remaining budget cannot cover the delta.
Instead, the overage is recorded as debt against the scope.
### When ALLOW_WITH_OVERDRAFT is right
- the action has already happened and the ledger must reflect it
- concurrent execution makes strict pre-commit enforcement impractical
- accurate accounting is more important than strict budget boundaries
- the team has processes to reconcile debt afterward
### How it works
Suppose a reservation held 100 units and actual usage was 150 units.
Remaining budget on the scope is only 20 units.
With ALLOW_WITH_OVERDRAFT:
1. The server checks: `(current_debt + delta) <= overdraft_limit`
2. If yes: the commit succeeds, the delta becomes debt, remaining goes negative
3. If no: the commit is rejected with `409 OVERDRAFT_LIMIT_EXCEEDED`
### The overdraft limit
The `overdraft_limit` is set per scope (outside the v0 protocol — typically via admin/operator configuration).
It defines the maximum debt a scope can carry.
If no overdraft_limit is set (or it is zero), ALLOW_WITH_OVERDRAFT behaves like ALLOW_IF_AVAILABLE.
## How to choose
A simple decision framework:
### Use REJECT when:
- estimates are reliable
- you prefer hard stops over partial accounting
- you can add estimation buffers
- you have retry logic for rejected commits
### Use ALLOW_IF_AVAILABLE when:
- estimates sometimes miss
- you want flexibility without debt
- remaining budget is the natural boundary
- you do not want to set up overdraft monitoring
### Use ALLOW_WITH_OVERDRAFT when:
- the work has already happened and must be accounted for
- concurrent execution makes exact pre-commitment impractical
- ledger accuracy is a hard requirement
- you have operator processes for debt reconciliation
## Overage policies on events
Events (`POST /v1/events`) also support all three overage policies with the same semantics.
Since events do not have a preceding reservation, the "overage" is simply whether the event amount exceeds available budget for the scope.
- REJECT: event is rejected if budget is insufficient
- ALLOW_IF_AVAILABLE: event is applied if budget can cover it; if the amount exceeds remaining budget, the charge is capped and the response includes a `charged` field showing the effective amount
- ALLOW_WITH_OVERDRAFT: event is applied with debt if necessary
## Overage policies and concurrency
REJECT and ALLOW_IF_AVAILABLE are atomic per commit.
ALLOW_WITH_OVERDRAFT is also atomic per individual commit, but the overdraft limit check is not atomic across concurrent commits. Multiple concurrent commits may each individually pass the check but collectively push debt past the limit.
This is by design. All commits represent work that already happened. The scope enters over-limit state and blocks future reservations until debt is repaid.
## A practical rollout
A safe rollout path for overage policies:
### Phase 1: Start with REJECT
Use REJECT with 15–20% estimation buffers. Monitor commit rejection rates.
### Phase 2: Move to ALLOW_IF_AVAILABLE for high-variance actions
For actions where estimation is difficult, switch to ALLOW_IF_AVAILABLE. This reduces rejected commits while maintaining budget boundaries.
### Phase 3: Use ALLOW_WITH_OVERDRAFT for must-record actions
For actions where the work has already happened (external API calls, side-effecting operations), use ALLOW_WITH_OVERDRAFT. Set up overdraft monitoring and debt reconciliation processes.
## Summary
The three commit overage policies give teams control over the tradeoff between budget strictness and ledger accuracy:
- **REJECT** — strictest, may create unaccounted gaps
- **ALLOW_IF_AVAILABLE** (default) — flexible, no debt, always commits, caps overage to available
- **ALLOW_WITH_OVERDRAFT** — most accurate, creates debt, requires reconciliation
Most systems benefit from using different policies for different action classes: REJECT for well-estimated actions, ALLOW_IF_AVAILABLE for variable ones, and ALLOW_WITH_OVERDRAFT for must-record side effects.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
# Correlation and Tracing in Cycles
Cycles produces data on four planes — runtime responses, webhook deliveries, audit-log entries, and emitted events. Correlation identifiers stitch those planes into a single causal picture.
The current YAML specifications define a W3C Trace Context-compatible correlation contract across the server suite. The reference services implement request and trace propagation; server-composed admin `correlation_id` values are also implemented. One current gap is called out below: the reference runtime does not yet populate the protocol's deterministic event-cluster `correlation_id`.
## The three-tier model
Cycles carries three correlation identifiers, each with a different grain.
| Identifier | Grain | Lifetime |
|---|---|---|
| `request_id` | One HTTP request | Per-request |
| `trace_id` | One logical operation (may span many requests) | Per-operation |
| `correlation_id` | An event-stream cluster or admin operation fan-out | Server-set: deterministic hash required by the runtime protocol, or an implemented operation ID for selected admin operations |
- **`request_id`** is server-generated for every inbound HTTP request. It appears in every error response, audit-log entry, and event that is causally downstream of that request. Use it to correlate the side effects of one specific HTTP call.
- **`trace_id`** identifies a logical operation that may cross several HTTP boundaries (for example: a client's reserve → provider call → commit) when the caller propagates it. It is a 32-hex-character W3C Trace Context-compatible identifier. Use it to join the Cycles records that carry the field and application telemetry for the rest.
- **`correlation_id`** is server-managed and scoped to event rows; callers do not set it through runtime request bodies or tracing headers. The runtime YAML requires a deterministic hash over `(tenant_id, scope, action_kind_or_risk_class, window, window_key)` for event-stream clusters, but the current reference runtime passes `null` on its implemented emit paths, so consumers must not depend on that join yet. The current admin server does populate explicit operation IDs for selected lifecycle, bulk, webhook, and tenant-close operations, such as `webhook_create:` and `webhook_bulk_action::`. See the [Event Payloads Reference](/protocol/event-payloads-reference) for implemented shapes.
::: warning Don't confuse with `metadata.trace_id`
[Standard Metrics and Metadata](/protocol/standard-metrics-and-metadata-in-cycles) documents application-level correlation keys that callers can put in the `metadata` map on commits and events — free-form strings the server stores but does not interpret. Name them distinctly (e.g. `external_trace_id`, `app_request_id`) rather than reusing `trace_id`/`request_id`, which are the server-managed identifiers described on this page (32-hex W3C, flowing on response headers, error bodies, events, audit rows, and webhook deliveries). The two coexist: your `metadata.external_trace_id` joins Cycles data with your own distributed tracing, while the server `trace_id` joins across Cycles planes.
:::
::: warning Propagation does not add fields to reservation records
The caller must send the same trace context on each related HTTP request; independently server-generated trace IDs are per request. The current `Reservation` model does not persist `trace_id`, and successful reserve, commit, release, and extend calls do not each emit lifecycle events. Keep the reservation ID and trace ID together in application logs when you need an end-to-end reserve/provider/settlement reconstruction.
:::
## Inbound header precedence
For every inbound HTTP request, the server derives `trace_id` in this strict order:
1. **`traceparent` header** — W3C Trace Context v00. Adopted when present AND well-formed.
2. **`X-Cycles-Trace-Id` header** — 32 lowercase hex characters (`^[0-9a-f]{32}$`). Used only when there is no valid `traceparent`.
3. **Server-generated** — 16 random bytes encoded as 32 lowercase hex, when neither header is present or well-formed. All-zero is invalid per W3C §3.2.2.3 and is re-rolled.
**Malformed tolerance.** A malformed correlation header MUST NOT cause the request to be rejected. The server silently falls through to the next rule. A misbehaving upstream proxy cannot break the API.
**Disagreement.** If both `traceparent` and `X-Cycles-Trace-Id` are present, valid, and disagree, `traceparent` wins.
## Outbound propagation
### On HTTP responses
Every response on every plane (`2xx`, `4xx`, `5xx`) carries:
```http
X-Cycles-Trace-Id: <32-hex-lowercase>
```
In addition, `ErrorResponse` bodies carry an optional `trace_id` field:
```json
{
"error": "BUDGET_EXCEEDED",
"message": "Insufficient budget in scope tenant:acme",
"request_id": "req-abc-123",
"trace_id": "0af7651916cd43dd8448eb211c80319c"
}
```
The `trace_id` field is OPTIONAL on the schema. Conformant v0.1.25.14+ runtime servers and v0.1.25.31+ admin servers populate it on every error response.
### On webhook deliveries
Every webhook delivery emitted by the events service carries two cross-surface headers on top of the existing delivery headers:
```http
X-Cycles-Trace-Id: <32-hex-lowercase>
traceparent: 00--<16-hex-span>-
```
- `X-Cycles-Trace-Id` — always present. Matches `X-Cycles-Trace-Id` on the originating response.
- `traceparent` — always present. W3C Trace Context v00. The `span-id` is freshly generated per outbound delivery (NOT reused from the inbound request). The `trace-flags` byte preserves the inbound W3C `traceparent` sampling decision when one was present; otherwise defaults to `01` (sampled).
These two are the normative delivery headers per the spec's required-header list. The reference implementation additionally sends an `X-Request-Id` header when the originating event carries a `request_id`, but that header is not part of the spec's required set — the normative carrier for `request_id` is the event envelope body.
### Inside emitted events
Standard event payloads carry:
| Field | Contract |
|---|---|
| `request_id` | Populated on every event causally downstream of an HTTP request — including async and queued work that spans thread / process boundaries. Pre-v0.1.25 events may lack it. |
| `trace_id` | OPTIONAL on the schema; populated by conformant v0.1.25.14+ runtime servers. |
| `correlation_id` | Server-managed. The runtime YAML requires deterministic event clusters, but the current reference runtime leaves this field absent on implemented runtime emits. Selected admin operations populate explicit IDs such as `webhook_create:`, bulk-action IDs, and cascade IDs. |
### Inside audit-log entries
Every `AuditLogEntry` carries `request_id` and (OPTIONAL) `trace_id`. These fields flow from the inbound request onto the audit row at write time. Admin-driven operations tagged with metadata such as `metadata.actor_type=admin_on_behalf_of` or `metadata.actor_type=admin` carry the same `trace_id` as the request that triggered them.
### Inside webhook delivery records
The `WebhookDelivery` schema carries three OPTIONAL fields as of governance-admin spec v0.1.25.28:
| Field | Purpose |
|---|---|
| `trace_id` | Captured at dispatch time from the originating event. Used by the events service to construct outbound `X-Cycles-Trace-Id` and `traceparent` headers. |
| `trace_flags` | W3C `trace-flags` byte (2 hex chars) to use when building the outbound `traceparent`. Preserves the inbound sampling decision. |
| `traceparent_inbound_valid` | Whether the originating HTTP request presented a valid W3C `traceparent`. When `true`, the dispatcher honors `trace_flags`; when `false` or null, it defaults to `01` (sampled). |
## Cross-plane propagation
`trace_id` travels:
- **Inbound request → response header** — echoed in `X-Cycles-Trace-Id` on every HTTP response.
- **Request → audit-log entry** — written for admin operations and the runtime's admin-on-behalf-of release path that create an audit row. Ordinary tenant reserve/commit/release calls do not each create an `AuditLogEntry`.
- **Request → emitted events** — attached to every event that is a side effect of the request, including events emitted from async workers (`ReservationExpiryService` for example mints a fresh `trace_id` per sweep batch so all `reservation.expired` events in that batch correlate to each other).
- **Events → webhook deliveries** — carried through to each outbound HTTP POST as `X-Cycles-Trace-Id` and embedded in `traceparent`.
- **Across thread / queue / process boundaries** — REQUIRED when work is causally downstream of a request. Internally originated sweepers may mint their own context; the reference reservation-expiry sweeper creates one trace ID per batch.
## Querying by correlation identifiers
The admin plane supports exact-match filters on correlation identifiers:
### `GET /v1/admin/events`
| Query parameter | Effect |
|---|---|
| `correlation_id=` | Narrows to events carrying one implemented server-composed correlation ID. Most useful for admin bulk actions, webhook lifecycle operations, and tenant-close cascades; current runtime events generally lack it. |
| `trace_id=<32-hex>` | Narrows to events emitted during one logical operation. May span multiple requests. |
| `request_id=` | Narrows to events that are side effects of one specific HTTP request. |
### `GET /v1/admin/audit/logs`
| Query parameter | Effect |
|---|---|
| `trace_id=<32-hex>` | Narrows to audit rows for one logical operation. |
| `request_id=` | Narrows to audit rows for one specific HTTP request. |
These are the only two admin endpoints with server-side request/trace filters; only the events endpoint also accepts `correlation_id`. The webhook-delivery list endpoints (`GET /v1/admin/webhooks/{subscription_id}/deliveries` and the tenant-scoped variant) accept only `status` / `from` / `to` / pagination parameters — there is no `trace_id` query parameter. To join deliveries into a trace, filter client-side on the `trace_id` field of each `WebhookDelivery` record.
Both filters are post-hydration predicates applied null-safely — entries with null field values (historical writes, off-request emissions, internal sweeper work) cannot satisfy a supplied filter value. Pre-v0.1.25.14 runtime entries and pre-v0.1.25.31 admin entries may lack `trace_id` and silently drop out of these joins; use `request_id` for those (the `request_id` contract predates `trace_id`).
### Phased-rollout tolerance
The four servers don't have to upgrade in lockstep. During a phased rollout, every combination is wire-compatible — but the `trace_id` filter will return partial results until every plane is at the minimum version:
| Plane | Minimum for `trace_id` population |
|---|---|
| Runtime (`cycles-server`) | v0.1.25.14 |
| Admin (`cycles-server-admin`) | v0.1.25.31 |
| Events (`cycles-server-events`) | v0.1.25.7 |
| Dashboard (`cycles-dashboard`) | v0.1.25.39 (consumes the admin filter) |
Concrete effect: if runtime is at v0.1.25.15 (writes `trace_id` on events) but admin is still at v0.1.25.26 (does NOT yet persist `trace_id` on `WebhookDelivery` records), webhook deliveries for that window will lack `trace_id` in admin queries even though the underlying event has one. Upgrade admin to v0.1.25.31+ to close the gap. Events v0.1.25.8 proactively back-fills `trace_id` onto `Delivery` records from the originating `Event.trace_id` during this window as a best-effort safety net.
If you can't upgrade every plane at once, pin `trace_id`-based alerting to queries that tolerate partial coverage (e.g., show results even when some webhook deliveries lack the field).
## A practical join
Given a single failing request, an operator can reconstruct the entire operation by reading the `trace_id` out of the error response, then walking the two admin endpoints that accept a `trace_id` filter — and filtering the delivery list client-side:
```bash
TID=0af7651916cd43dd8448eb211c80319c
# 1. The request reached the server. What decision was recorded?
curl -s "http://localhost:7979/v1/admin/audit/logs?trace_id=$TID" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
| jq '.logs[] | {operation, status, error_code, metadata}'
# 2. What events were emitted as side effects?
curl -s "http://localhost:7979/v1/admin/events?trace_id=$TID" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
| jq '.events[] | {event_type, data}'
# 3. What webhook deliveries went out as a consequence?
# (no trace_id query parameter here — filter client-side on the record's trace_id field)
curl -s "http://localhost:7979/v1/admin/webhooks//deliveries" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
| jq --arg tid "$TID" '.deliveries[] | select(.trace_id == $tid) | {status, response_status, trace_flags}'
```
Three calls, one trace ID — two server-side filters plus one client-side filter — return the records that each plane retained for that trace. Application authorization and external execution outcomes still come from your own trace-linked logs.
## Logging `trace_id` in client code
Conformant client logging captures both `request_id` and `trace_id` from the error response. When should you use which?
- **`request_id`** is enough when you're debugging a single failed call — it points at one audit row and its immediate side effects.
- **`trace_id`** is the right choice when the symptom may involve retries, async commits, admin-on-behalf-of releases, or cross-plane fan-out. It narrows to one logical operation regardless of how many HTTP hops happened.
Log both. `request_id` is the tightest predicate; `trace_id` is the widest useful one.
```python
try:
result = summarize(text)
except CyclesProtocolError as e:
# SDK field (present on v0.1.25-aware SDKs) with response-header fallback.
trace_id = getattr(e, "trace_id", None) or (
e.response_headers.get("X-Cycles-Trace-Id") if hasattr(e, "response_headers") else None
)
logger.error(
"cycles error",
extra={
"error_code": e.error_code,
"status": e.status,
"request_id": e.request_id,
"trace_id": trace_id,
},
)
raise
```
The SDKs expose `trace_id` on the error object where available. Older SDK versions that predate v0.1.25 support return `None` from `e.trace_id`; the fallback above pulls from the `X-Cycles-Trace-Id` response header instead. That header is always present on v0.1.25.14+ runtime and v0.1.25.31+ admin responses, so the fallback covers every case where the server populates the field.
## Backward compatibility
The correlation contract is purely additive:
- No new REQUIRED fields on existing schemas.
- Old clients silently ignore the new `X-Cycles-Trace-Id` response header.
- Old webhook subscribers silently ignore the new outbound `X-Cycles-Trace-Id` and `traceparent` headers (and the reference implementation's `X-Request-Id`).
- Servers that predate the contract (runtime below v0.1.25.14, admin below v0.1.25.31, events below v0.1.25.7) remain wire-compatible: `trace_id` is an OPTIONAL property, and `ErrorResponse.additionalProperties: false` is preserved because `trace_id` is a DECLARED property, not an undeclared extra.
## Next steps
- [Webhook Event Delivery Protocol](/protocol/webhook-event-delivery-protocol) — the outbound headers in context
- [Error Codes and Error Handling](/protocol/error-codes-and-error-handling-in-cycles) — `trace_id` in error responses
- [Searching and Sorting Admin List Endpoints](/how-to/searching-and-sorting-admin-list-endpoints) — the audit-log filter DSL, including `trace_id` and `request_id` filters
- [Force-Releasing Stuck Reservations](/how-to/force-releasing-stuck-reservations-as-an-operator) — uses `trace_id` to audit admin-on-behalf-of releases
# CyclesEvidence Envelopes in Cycles
This page is the protocol reference for CyclesEvidence — the signed, content-addressed audit envelope behind the [verifiable-audit concept](/concepts/cycles-evidence-verifiable-audit-for-agent-decisions). For *why* it exists, start there; this page is the *how*.
The consumer surface (`cycles_evidence` on responses, `GET /v1/evidence/{id}`, and `GET /v1/.well-known/cycles-jwks.json`) is defined in `cycles-protocol-v0.yaml`. The envelope and signer-authority rules are specified in [`cycles-evidence-v0.2.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/cycles-evidence-v0.2.yaml). The wire `schema_version` remains `cycles-evidence/v0.1` for compatibility; v0.2 adds the normative JWK Set authority layer around that envelope shape.
## The `cycles_evidence` reference
Every decide / reserve / commit / release response — and budget/lifecycle **denial** responses — may carry an optional `cycles_evidence`:
```json
"cycles_evidence": {
"evidence_id": "8403bed4…7030",
"cycles_evidence_url": "https://cycles.example.com/v1/evidence/8403bed4…7030"
}
```
- `evidence_id` — lowercase 64-hex SHA-256, the content address of the signed envelope.
- `cycles_evidence_url` — `{server_id}/evidence/{evidence_id}`. `server_id` already includes the `/v1` base, so the join adds only `/evidence/{id}`.
It is **transport metadata, not attested** — present for the caller's convenience and computed over the response *without* this field (see [Non-self-referential](#non-self-referential) below). It is absent when evidence emission is disabled, or for errors raised before a decision was reached (validation/auth failures). Additive and `@JsonInclude(NON_NULL)`: a client that ignores it is unaffected.
## The envelope and its five artifact types
`GET /v1/evidence/{id}` returns the signed envelope verbatim:
```json
{
"schema_version": "cycles-evidence/v0.1",
"artifact_type": "reserve",
"server_id": "https://cycles.example.com/v1",
"signer_did": "b10554…c522",
"issued_at_ms": 1781436904050,
"trace_id": "b2a0ab88…dc02",
"payload": { "reserve": { "request": { … }, "response": { … } } },
"evidence_id": "8403bed4…7030",
"signature": "4bc8cb9a…8c08"
}
```
| `artifact_type` | Endpoint | Payload |
|---|---|---|
| `decide` | `POST /v1/decide` | `{ request, response }` |
| `reserve` | `POST /v1/reservations` | `{ request, response }` |
| `commit` | `POST /v1/reservations/{id}/commit` | `{ reservation_id, request, response }` |
| `release` | `POST /v1/reservations/{id}/release` | `{ reservation_id, request, response }` |
| `error` | any of the above (4xx/5xx) | `{ endpoint, http_status, [reservation_id], [request], response }` |
`commit` / `release` (and commit/release `error`s) **hoist `reservation_id`** into the payload so an evidence-only reader can reconstruct the authorization → settlement chain without the URL.
### Denials → the `error` artifact
A non-dry `reserve` over budget is **not** a `200` with `decision: DENY` — it is an `HTTP 409` with `error: BUDGET_EXCEEDED`, captured as an `error` envelope (`endpoint: "POST /v1/reservations"`, `http_status: 409`). The other post-evaluation budget/lifecycle denials behave the same — `BUDGET_FROZEN`, `BUDGET_CLOSED`, `OVERDRAFT_LIMIT_EXCEEDED`, `DEBT_OUTSTANDING`, `TENANT_CLOSED`, `UNIT_MISMATCH`, and the commit/release terminal-state denials `RESERVATION_FINALIZED` (409) and `RESERVATION_EXPIRED` (410). Pre-evaluation failures (validation, auth, malformed body) carry **no** `cycles_evidence` — no decision was reached, so there is nothing to attest. (A dry-run preflight denial, by contrast, is a `200` captured as `reserve` evidence — it is the canonical "would this be allowed?" attestation.)
## `evidence_id` — the content-hash recipe (normative)
1. Build the envelope with every field populated **except** `evidence_id` and `signature`, both set to the empty string `""`.
2. Canonicalize per RFC 8785 (JCS); UTF-8 encode.
3. `evidence_id` = lowercase hex SHA-256 of those bytes.
Because the id is a pure function of the contents (no private key), **Cycles computes it synchronously and returns it on the response**, even though signing happens later.
## Signature derivation (normative)
1. Take the envelope with `evidence_id` now populated and `signature` still `""`.
2. Canonicalize again (JCS), UTF-8 encode.
3. `signature` = lowercase hex of the Ed25519 signature over those bytes, using the server's signing key (named by `signer_did`).
This is the same id-then-signature ordering used elsewhere in the agent-trust ecosystem, so a consumer that can verify one of those receipts can verify a CyclesEvidence envelope with the same primitives.
## Non-self-referential
The `cycles_evidence` ref is stamped onto the response **after** `evidence_id` is computed. So the `payload..response` inside the envelope never contains `cycles_evidence` — the content hash is never self-referential. The response mirrors in the normative v0.2 spec keep `additionalProperties: false` and omit the ref to make this explicit.
## How to verify
Given an envelope:
1. **Re-derive `evidence_id`** per the recipe above and compare byte-for-byte. Mismatch ⇒ tampered or canonicalization error.
2. **Resolve signer authority** by fetching `GET {server_id}/.well-known/cycles-jwks.json` and selecting the Ed25519 JWK whose `[cycles_nbf_ms, cycles_exp_ms)` window covers the envelope's `issued_at_ms`. The selected key's public bytes must match `signer_did`.
3. **Verify the Ed25519 `signature`** (with `evidence_id` populated, `signature` emptied) against that public key.
4. **Check the `artifact_type` ↔ `payload` pairing** (e.g. `artifact_type: commit` requires `payload.commit`).
Signature *validity* proves the envelope was signed by the key in `signer_did`. Signer *authority* proves that key was published by the issuing `server_id` for the envelope's issuance window. The JWK Set is the normative v0.2 authority layer. If a server does not publish JWKS, consumers can still run in a pinned-signer (`binding_only`) posture by comparing `signer_did` to an expected signer out of band. Why validity and authority are different questions: [A Valid Signature Doesn't Tell You Who Signed It](/blog/a-valid-signature-doesnt-tell-you-who-signed-it).
### Verification dispositions
A conformant verifier reports **exactly one** of five dispositions, keeping the two axes distinct:
- `authentic` — signature valid **and** authority established: the verifying key is the one window-covering key selected deterministically from `server_id`'s JWK Set.
- `binding_only` — signature cryptographically valid, but no JWKS authority was resolved (raw-hex with no set lookup, or a pin-only deployment). The companion `signer_pin_matched` boolean says whether an `expected_signer` pin was present and matched `signer_did`.
- `signer_authority_failed` — resolution *succeeded* (the JWK Set was fetched and parsed) but the key is not authorized for this envelope: DID↔`server_id` hash mismatch, no window-covering key, the raw-hex key absent from the set, or an ambiguous / duplicate-`kid` selection. Neither a network failure nor a forgery.
- `signer_resolution_failed` — the JWK Set could not be retrieved or parsed (network, 404/transient, unparseable body). About *obtaining* the set, not searching it; establishes nothing about the bytes and must never be reported as `signature_invalid`.
- `signature_invalid` — the bytes do not verify against the resolved/named key (tamper).
## Signer-key resolution and rotation
### The two `signer_did` forms
`signer_did` names the Ed25519 signer in one of two forms:
1. **Raw hex** — the 32-byte public key as 64 lowercase hex chars. Self-describing: a verifier checks the `signature` against it directly, with no resolution (the only form v0.1 requires). Authority then means "a window-covering JWK whose `x` decodes to the same 32 bytes exists in `server_id`'s set" — no `kid` needed (a JWK's `x` is `base64url(pubkey)`, the raw-hex form is `hex(pubkey)`; same bytes).
2. **`did:cycles:#`** — `` is lowercase `hex(sha256(server_id))`, binding the DID to the envelope's `server_id`; the `#` fragment names the key within the server's JWK Set (it equals that JWK's `kid`). A verifier resolves this form to establish signer *authority*, not just signature validity. A DID whose `` doesn't equal `hex(sha256(server_id))` fails authority — the DID does not bind to this envelope's `server_id`.
Step 2 needs the right public key. A server publishes its keys as a JWK Set:
```
GET {server_id}/.well-known/cycles-jwks.json # operationId: getEvidenceJwks
```
Public and unauthenticated (it carries public keys only). Each entry is an Ed25519 OKP JWK with a validity window:
```json
{
"kty": "OKP", "crv": "Ed25519", "alg": "EdDSA",
"x": "",
"kid": "2026-h2",
"cycles_nbf_ms": 1781000000000,
"cycles_exp_ms": 1796000000000,
"status": "retired"
}
```
The **active** key omits `cycles_exp_ms` (open-ended) and has `status: active`. A server not doing signer-key resolution publishes nothing — the endpoint `404`s, and consumers stay on the pinned-signer (`binding_only`) path.
**Window-gated selection.** A verifier selects the key whose `[cycles_nbf_ms, cycles_exp_ms)` window covers the envelope's `issued_at_ms` — never "the current key." So an envelope signed two rotations ago still verifies against the key that was valid when it was signed; the set keeps **retired** keys for exactly this. `status` is advisory — selection is by window. (The forgery this prevents: [Rotating Keys Shouldn't Rewrite History](/blog/rotating-keys-shouldnt-rewrite-history).)
**Exactly one candidate.** Selection is a total function with no implementation discretion: the window gate must be satisfied by **exactly one** candidate key. Zero covering keys, overlapping windows that leave two candidates, a duplicate `kid` in the set, a `did:cycles` fragment with no matching `kid`, or (raw-hex) zero or multiple window-covering JWKs matching the key bytes — all are `signer_authority_failed`, never a silent pick.
### Rotating the signing key (operator procedure)
The windows must tile without overlapping. On rotation:
1. Generate the new Ed25519 key pair and deploy the private key only to `cycles-server-events` as `EVIDENCE_SIGNING_PRIVATE_KEY_HEX`.
2. Make the new public key active on the runtime server — `EVIDENCE_SIGNING_SIGNER_DID` = the new raw-hex public key, `EVIDENCE_SIGNING_KID` = the active JWK `kid`, and `EVIDENCE_SIGNING_NBF_MS` = the rotation time (epoch ms).
3. Deploy the same public `EVIDENCE_SIGNING_SIGNER_DID` and `EVIDENCE_SERVER_ID` to `cycles-server-events` so the worker signs envelopes with the same identity the runtime publishes and used when computing `evidence_id`.
4. Append the old public key to the runtime server's `EVIDENCE_SIGNING_RETIRED_KEYS` — a JSON array of `{"signer_did","kid","nbf_ms","exp_ms"}` — with `exp_ms` = that same rotation time.
The retiring key's window then ends exactly where the new key's begins.
**Fail-safe, never fail-closed.** If `nbf-ms` is left below the latest retired `exp_ms`, the published active window is **clamped up** to that boundary (with a warning), so the current key is never published as authoritative for pre-rotation `issued_at_ms` by accident. A retired entry that can't be published (malformed hex, empty/inverted window, out-of-range bound, duplicate `kid`) is dropped, not fatal; if the whole `retired-keys` value is unusable, the server logs an error and keeps serving the active key — it never refuses to publish, which would break verification of *all* current evidence.
## Producer / signer split
- **`cycles-server`** computes `evidence_id` synchronously, returns `cycles_evidence`, serves `GET /v1/evidence/{id}` and `GET /v1/.well-known/cycles-jwks.json`, and holds only the **public** identity.
- **`cycles-server-events`** asynchronously builds, **Ed25519-signs** (the private key lives only here), and stores the envelope content-addressed. It recomputes the id and **dead-letters on drift**, so producer/signer config mismatch fails closed.
Because signing is async, a fetch immediately after the response may return a transient `404` — treat it as not-yet-available and retry.
## Enabling it
Evidence is **off until a shared signing identity is configured**:
- `EVIDENCE_SERVER_ID` on both services — the issuer base URL, including `/v1`, used in evidence URLs and envelopes.
- `EVIDENCE_SIGNING_SIGNER_DID` on both services — the raw-hex public Ed25519 key.
- `EVIDENCE_SIGNING_PRIVATE_KEY_HEX` only on `cycles-server-events` — the raw-hex private Ed25519 key.
- `EVIDENCE_SIGNING_KID`, `EVIDENCE_SIGNING_NBF_MS`, and `EVIDENCE_SIGNING_RETIRED_KEYS` only on `cycles-server` — public JWKS metadata and rotation history.
See the operator [identity enablement runbook](https://github.com/runcycles/cycles-server-events/blob/main/docs/evidence-identity-enablement.md).
## Related
- [CyclesEvidence: Verifiable Audit for Agent Decisions](/concepts/cycles-evidence-verifiable-audit-for-agent-decisions) — the why.
- [Error Codes and Error Handling](/protocol/error-codes-and-error-handling-in-cycles) — the denial codes that surface as `error` evidence.
- [Correlation and Tracing](/protocol/correlation-and-tracing-in-cycles) — the `trace_id` carried on every envelope.
# Debt, Overdraft, and the Over-Limit Model in Cycles
Budget enforcement in Cycles is designed to prevent unbounded execution.
But real systems sometimes cross budget boundaries.
A model call may cost more than expected.
A retry may push actual usage past the reserved amount.
Concurrent commits may collectively exceed available budget.
When this happens, the system needs a clear model for what occurred, how much debt exists, and when normal operation can resume.
That is what the debt and overdraft model provides.
## The problem: what happens when actuals exceed budget?
In a strict system, any overage is rejected. The commit fails.
That is the behavior with overage policy `REJECT`. The default policy since v0.1.24 is `ALLOW_IF_AVAILABLE`, which caps the charge to available budget instead of rejecting.
But sometimes the work has already happened. The model call returned. The tool executed. The side effect occurred.
Rejecting the commit at that point does not undo the work. It just creates an unaccounted gap in the ledger.
The overdraft model solves this by allowing the system to record what actually happened, even when budget is insufficient, while clearly marking the resulting debt.
## How overdraft works
When a commit or event uses the `ALLOW_WITH_OVERDRAFT` overage policy and the actual amount exceeds available budget:
1. The server checks whether `(current_debt + delta) <= overdraft_limit` for each affected scope
2. If yes: the commit succeeds, the delta is added to debt, and remaining can go negative
3. If no: the commit is rejected with `409 OVERDRAFT_LIMIT_EXCEEDED`
For commits, the delta is the overage beyond what was reserved. Events (`POST /v1/events`) have no reservation to net against — the full actual amount is the debit, so the overdraft check becomes `(current_debt + actual) <= overdraft_limit` and, on success, the full actual is added to debt.
This means the system can absorb overages up to a defined limit, then stops.
## Key concepts
### debt
Debt represents actual consumption that occurred when insufficient budget was available.
It is a real number in the balance ledger. It is not theoretical. It reflects work that happened and was accounted for, even though budget did not fully cover it.
### overdraft_limit
The overdraft limit defines the maximum debt a scope is allowed to accumulate.
If the limit is 10,000 units, the scope can absorb up to 10,000 units of debt before further overages are rejected.
If the limit is absent or zero, no overdraft is permitted (the scope behaves as if using `ALLOW_IF_AVAILABLE`).
### is_over_limit
When debt exceeds the overdraft limit — typically due to concurrent commits — the scope enters an over-limit state.
The same state can also be entered with **zero debt**: under `ALLOW_IF_AVAILABLE`, when the full overage delta cannot be covered, the server caps the charge and sets `is_over_limit` to `true` — without ever creating debt.
In this state:
- `is_over_limit` is `true` on the balance
- all new reservations against that scope are rejected with `409 OVERDRAFT_LIMIT_EXCEEDED`
- existing active reservations can still be committed or released normally
The scope remains blocked until reconciled — debt repaid below the overdraft limit, or, on the zero-debt path, `is_over_limit` cleared by operator action.
### remaining (can be negative)
In Cycles, the `remaining` balance uses a `SignedAmount` — it can go negative.
A negative remaining means the scope has consumed more than its allocated budget. This is only possible when overdraft is enabled.
The formula is: `remaining = allocated - spent - reserved - debt`
## How concurrent commits create over-limit state
The overdraft limit check is per-commit, not atomic across concurrent commits.
Consider this scenario:
- Scope has overdraft_limit = 5,000
- Current debt = 0
- Two concurrent commits each need 4,000 in overage
Each commit individually checks: `(0 + 4,000) <= 5,000` → passes.
Both commits succeed. But now debt = 8,000, which exceeds overdraft_limit = 5,000.
The scope enters over-limit state.
This is by design. The commits represent work that already happened. Rejecting them would create unaccounted gaps. Instead, the system records the reality and blocks future work until the situation is resolved.
## What happens when a scope is over-limit
When `is_over_limit` is true:
1. **New reservations are blocked.** Any attempt to reserve against the scope returns `409 OVERDRAFT_LIMIT_EXCEEDED`.
2. **Existing reservations can be finalized.** Active reservations can still be committed or released. This prevents in-flight work from being stranded.
3. **Decide returns DENY.** The decide endpoint returns `DENY` with an appropriate reason code instead of a 409 error.
4. **The block is automatic.** No operator action is needed to enforce the block — it is protocol-level.
5. **Recovery is through funding or operator action.** The scope is unblocked when debt is repaid below the overdraft limit through budget funding operations (which are outside the scope of the v0 protocol). When the over-limit state was entered with zero debt (the capped `ALLOW_IF_AVAILABLE` path), there is no debt to repay — an operator clears `is_over_limit` directly.
## Debt vs budget denial
These are different situations:
**Budget denial** (`BUDGET_EXCEEDED`): the request is refused and no debt is created. At commit time this happens only under the `REJECT` overage policy — `ALLOW_IF_AVAILABLE` never rejects a commit; it caps the charge to available remaining instead. At reservation time, a denial occurs when the estimate exceeds remaining budget, regardless of overage policy.
**Debt creation** (`ALLOW_WITH_OVERDRAFT`): the scope has insufficient budget, but the overage policy allows debt. The commit succeeds, debt is recorded, and the ledger reflects reality.
**Over-limit block** (`OVERDRAFT_LIMIT_EXCEEDED`): debt has exceeded the overdraft limit. Future reservations are blocked until debt is repaid.
**Outstanding debt block** (`DEBT_OUTSTANDING`): debt exists and no overdraft limit is configured (overdraft_limit is absent or 0). New reservations are blocked because the scope has unresolved debt with no overdraft tolerance. When an `overdraft_limit > 0` is set, debt within the limit does not block new reservations.
Note: when `is_over_limit` is true, the server returns `OVERDRAFT_LIMIT_EXCEEDED` instead of `DEBT_OUTSTANDING`, even if debt > 0. The over-limit error takes precedence.
## When to use overdraft
Overdraft is most useful when:
- the cost of unaccounted work is worse than the cost of debt
- model calls and tool actions cannot be undone after execution
- the system needs accurate ledger state even under budget pressure
- concurrent execution makes strict pre-commit enforcement impractical
- operators prefer to reconcile debt after the fact rather than lose ledger accuracy
## When not to use overdraft
Overdraft is less appropriate when:
- strict budget enforcement is required (use `REJECT`)
- the system should never exceed allocated budget under any circumstances
- debt reconciliation processes are not in place
- the team prefers hard stops over post-hoc resolution
## Monitoring over-limit state
The spec recommends operators monitor over-limit scopes with:
- **Dashboard** showing scopes where `is_over_limit` is true
- **Warning alerts** when debt reaches 80% of overdraft_limit
- **Critical alerts** when debt exceeds 100% (over-limit state)
- **Metrics** tracking `debt_utilization = debt / overdraft_limit`
The recommended operator runbook:
1. Investigate which commits caused the over-limit state
2. Determine whether the overdraft limit should increase (normal variance) or whether this is anomalous (incident)
3. Fund the scope to repay debt below the limit
4. Monitor that `is_over_limit` returns to false
5. Operations resume automatically
## Summary
The debt and overdraft model in Cycles provides a controlled way to handle budget overages in real production systems.
Instead of choosing between "reject everything" and "allow everything," teams can define an overdraft limit that absorbs reasonable overages while blocking new work when debt becomes excessive.
The key mechanisms:
- **overdraft_limit** defines how much debt a scope can tolerate
- **debt** records actual consumption beyond available budget
- **is_over_limit** blocks new reservations when debt exceeds the limit — or when a capped `ALLOW_IF_AVAILABLE` overage could not be fully covered, even with zero debt
- **recovery** happens through budget funding (or operator clearing on the zero-debt path), which is outside the v0 protocol scope
This gives teams accurate ledger state, bounded risk, and a clear path to resolution.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
# Dry Run: Shadow Mode Evaluation in Cycles
Before enforcing budget limits in production, teams need a way to test the full reservation path without actually holding budget.
That is what dry run provides.
Setting `dry_run: true` on a reservation request tells the server to evaluate the request as if it were real — including scope derivation, budget checks, decision logic, and cap computation — but without modifying any budget state.
## What dry run does
A dry run reservation request:
1. Evaluates the Subject and derives canonical scopes
2. Checks budget availability across all derived scopes
3. Returns a decision (ALLOW, ALLOW_WITH_CAPS, or DENY)
4. Returns affected_scopes showing which scopes were evaluated
5. Returns caps if the decision is ALLOW_WITH_CAPS
6. Optionally returns balance snapshots for operator visibility
All of this happens without:
- creating a reservation
- modifying any balance
- requiring a subsequent commit or release
## Dry run response rules
The protocol defines specific rules for dry run responses:
### reservation_id and expires_at_ms are absent
A dry run does not create a reservation, so `reservation_id` and `expires_at_ms` MUST be absent from the response (not present with a null value — the fields must not appear).
### affected_scopes is always populated
Regardless of the decision outcome — ALLOW, ALLOW_WITH_CAPS, or DENY — the `affected_scopes` field must be populated.
This is important for debugging. Even when a dry run returns DENY, the client can see which scopes were evaluated and identify where the bottleneck is.
### caps follow the same rules
If the decision is ALLOW_WITH_CAPS, `caps` is present with the same constraints that would apply to a real reservation.
If the decision is ALLOW or DENY, `caps` is absent.
### reason_code on DENY
When a dry run returns DENY, the `reason_code` field should be populated. This is the primary diagnostic signal for understanding why the dry run was denied.
`DecisionReasonCode` is an open string (as of v0.1.25) with seven documented known values: `BUDGET_EXCEEDED`, `BUDGET_FROZEN`, `BUDGET_CLOSED`, `BUDGET_NOT_FOUND`, `OVERDRAFT_LIMIT_EXCEEDED`, `DEBT_OUTSTANDING`, `TENANT_CLOSED` (added in spec v0.1.25.13 — a fresh dry run on a closed owning tenant returns `decision=DENY` with this reason, never `409 TENANT_CLOSED`; the persisting create surfaces the same condition as the 409). Clients MUST handle unknown values gracefully — extension specs may add new reason codes additively. See [Decision reason codes](/protocol/error-codes-and-error-handling-in-cycles#decision-reason-codes) for full semantics.
The same field is used by `/v1/decide` responses. Note that on dry_run reserve, the `BUDGET_NOT_FOUND` reason code corresponds to a condition that non-dry reserve would surface as `HTTP 404` with `error=NOT_FOUND` — the wire shape is different, but the underlying "no budget at any derived scope" condition is the same.
### balances are recommended but optional
The server may include balance snapshots in the response. These reflect the current state without any mutation — they show what balances look like without the reservation being applied.
Balance snapshots are recommended for operator visibility but are not required.
## Dry run vs decide
Both dry run and the decide endpoint (`POST /v1/decide`) evaluate budget without modifying state. But they differ in important ways:
### Scope of evaluation
- **decide** is a lightweight preflight check that returns a decision, optional caps, and affected scopes
- **dry_run** evaluates the full reservation creation path, including all normative rules that apply to real reservations
### Response completeness
- **decide** returns decision, caps, reason_code, retry_after_ms, and affected_scopes
- **dry_run** returns everything a real reservation would (except reservation_id and expires_at_ms), including balance snapshots
### Budget denial semantics
- **decide** returns a 200 response with a decision value for budget-state conditions — insufficient budget, debt, and over-limit all surface as `decision: DENY`, never as a 409. Request-validity errors are the exception to this pattern: a wrong unit still returns `400 UNIT_MISMATCH`, and malformed or unauthorized requests return their usual 4xx errors
- **dry_run** may return DENY as the decision value for insufficient budget (unlike a live reservation, which would return `409 BUDGET_EXCEEDED`); as with decide, request-validity errors still return 4xx
This is a subtle but important distinction: a live reservation with insufficient budget fails with a 409 error. A dry run with insufficient budget succeeds with a 200 response containing `decision: DENY`.
### When to use each
Use **decide** for:
- quick feasibility checks
- UI gating
- planning and routing between alternatives
Use **dry_run** for:
- full shadow-mode evaluation of reservation logic
- validating scope derivation and affected scopes
- testing budget policy before enabling enforcement
- monitoring what would happen if enforcement were live
## How to use dry run for shadow mode rollout
The typical shadow mode rollout pattern:
### Phase 1: Observe
Enable dry run on all reservation requests. Log the decisions but do not act on them.
This shows what enforcement would look like without any production impact.
### Phase 2: Alert
Configure alerts for dry run DENY decisions. Investigate whether these denials are expected or would indicate misconfigured budgets.
### Phase 3: Enforce selectively
Switch specific action classes from dry run to live enforcement. Keep others in dry run mode.
### Phase 4: Full enforcement
Once confidence is high, switch all action classes to live enforcement.
## Dry run in client code
The decorator/annotation supports dry run. When enabled, the client evaluates the reservation without holding budget. The decorated function does not execute — a `DryRunResult` is returned instead (Python) or the decision is logged for monitoring (Java). This allows teams to observe what would have happened under enforcement without affecting runtime behavior.
::: code-group
```python [Python]
from runcycles import cycles
@cycles(estimate=1000, dry_run=True)
def summarize(text: str) -> str:
return call_llm(text)
```
```java [Java (Spring Boot)]
@Cycles(value = "1000", dryRun = true)
public String summarize(String text) {
return chatModel.call(text);
}
```
:::
## Idempotency on dry run
Dry run requests support idempotency keys. On replay with the same key, the server returns the original response.
However, since dry run does not create a reservation, there is no reservation_id to replay. The replayed response reflects budget state at the time of the original call, not the current state.
## Practical example
A team is rolling out budget enforcement for their support bot. They configure dry run on all model calls:
```json
{
"idempotency_key": "shadow-run-001",
"subject": { "tenant": "acme", "app": "support-bot" },
"action": { "kind": "llm.completion", "name": "openai:gpt-4o" },
"estimate": { "unit": "USD_MICROCENTS", "amount": 500000 },
"dry_run": true
}
```
The response comes back:
```json
{
"decision": "ALLOW_WITH_CAPS",
"affected_scopes": ["tenant:acme", "tenant:acme/app:support-bot"],
"caps": {
"max_tokens": 2048
}
}
```
This tells the team: if enforcement were live, the request would be allowed but with a token cap. They can use this data to tune budgets before enabling real enforcement.
## Summary
Dry run provides full reservation-path evaluation without budget mutation:
- **decision** is returned as if the reservation were live
- **affected_scopes** is always populated, even on DENY
- **reservation_id** and **expires_at_ms** are absent
- **caps** follow the same rules as live reservations
- **balances** reflect non-mutating evaluation
Use dry run for shadow mode rollouts, policy testing, and building confidence in budget configuration before enabling enforcement.
For quick feasibility checks without full reservation evaluation, use the decide endpoint instead.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
# Error Codes and Error Handling in Cycles
Cycles uses structured error responses with specific error codes for every failure condition.
Understanding these codes is essential for building a production integration. Each code tells the client exactly what happened and what to do about it.
## Error response format
Every error response follows the same structure:
```json
{
"error": "BUDGET_EXCEEDED",
"message": "Insufficient budget in scope tenant:acme",
"request_id": "req-abc-123",
"trace_id": "0af7651916cd43dd8448eb211c80319c",
"details": {}
}
```
- **error** — a machine-readable error code from the fixed enum
- **message** — a human-readable explanation
- **request_id** — a unique identifier for one HTTP request
- **trace_id** — OPTIONAL. 32-hex W3C Trace Context identifier for the logical operation this request belongs to. Conformant v0.1.25.14+ runtime servers and v0.1.25.31+ admin servers populate it on every error response. See [Correlation and Tracing](/protocol/correlation-and-tracing-in-cycles).
- **details** — optional additional context
Every response — error or success — also carries an `X-Cycles-Trace-Id` HTTP response header with the same 32-hex identifier. Log both `request_id` and `trace_id` when handling errors; `trace_id` is the cross-plane join key for admin audit, events, and webhook delivery queries.
## The error codes
The runtime protocol defines 17 wire error codes (the `ErrorCode` enum in the runtime OpenAPI spec — `LIMIT_EXCEEDED` was added in spec v0.1.25.12, `TENANT_CLOSED` in spec v0.1.25.13). `TENANT_CLOSED` mirrors the governance/admin plane's lifecycle error of the same name — raised by the [tenant-close cascade](/protocol/tenant-close-cascade-semantics) against objects owned by closed tenants — so all 17 codes covered here are now part of the runtime wire contract. The admin-plane error enum has additional codes that aren't; see the admin OpenAPI spec for the full set.
Each code has a specific HTTP status code and meaning.
### INVALID_REQUEST (400)
The request is malformed or missing required fields.
Common causes:
- missing required fields (subject, action, estimate, idempotency_key)
- Subject with only `dimensions` and no standard field (tenant, workspace, app, workflow, agent, toolset)
- field values exceeding length limits
- invalid parameter values
**What to do:** fix the request. This is not retryable without changes.
### UNAUTHORIZED (401)
The `X-Cycles-API-Key` header is missing or the API key is invalid.
**What to do:** check the API key configuration. Not retryable without a valid key.
### FORBIDDEN (403)
The request is authenticated but not authorized for the target resource.
Common causes:
- Subject.tenant does not match the effective tenant derived from the API key
- attempting to commit/release/extend a reservation owned by a different tenant
- querying balances for a different tenant
**What to do:** ensure the tenant in the Subject matches the API key's tenant. Not retryable without fixing the tenant mismatch.
### NOT_FOUND (404)
The runtime plane uses a single `NOT_FOUND` wire code for all resource-not-found conditions. The `message` field carries the specific reason. Two distinct conditions surface here:
**Missing reservation.** The specified reservation ID does not exist. This is different from `RESERVATION_EXPIRED` — a 404 means the reservation was never created, while `RESERVATION_EXPIRED` means it existed but its TTL has passed. **What to do:** verify the reservation ID. If the client lost the ID, use `GET /v1/reservations` with the `idempotency_key` filter to recover it.
**Missing budget.** Returned on `POST /v1/reservations` and `POST /v1/events` when no budget ledger exists at any derived scope in any unit. The wire response looks like:
```json
{
"error": "NOT_FOUND",
"message": "Budget not found for provided scope: tenant:acme/workspace:prod",
"request_id": "req-abc-123"
}
```
Distinct from `UNIT_MISMATCH (400)` — "missing budget" means *no budget exists at all*, while `UNIT_MISMATCH` means a budget exists at the scope but in a different unit than the request. **What to do:** create a budget via `POST /v1/admin/budgets` for at least one scope in the hierarchy. See [Budget Allocation and Management](/how-to/budget-allocation-and-management-in-cycles#how-budget-lookup-works-during-reservations).
On `POST /v1/decide` and `POST /v1/reservations` with `dry_run=true`, the "missing budget" condition does NOT surface as a 404. Those endpoints return `200` with `decision=DENY` and `reason_code=BUDGET_NOT_FOUND` instead — see [Decision reason codes](#decision-reason-codes) below.
### BUDGET_EXCEEDED (409)
Budget is insufficient for the requested operation.
This appears in three contexts:
1. **Reservation:** the scope does not have enough remaining budget for the estimate
2. **Commit with REJECT policy:** actual exceeds reserved
3. **Event with REJECT policy:** insufficient budget for the event amount
Note: commits with ALLOW_IF_AVAILABLE never return 409. Instead, the charge is capped to the available remaining budget.
**What to do:** depends on context:
- for reservations: degrade (smaller model, fewer tools), defer, or deny the action
- for commits: the work already happened — consider switching to ALLOW_IF_AVAILABLE or ALLOW_WITH_OVERDRAFT
- for events: adjust the amount or change the overage policy
### BUDGET_FROZEN (409)
The budget scope has been frozen by an operator. Operations that would modify the budget (reserve, commit, event) are rejected while the scope is frozen.
**What to do:** wait for the operator to unfreeze the budget, or escalate. Not retryable until the freeze is lifted.
### BUDGET_CLOSED (409)
The budget scope has been permanently closed. No further budget operations are allowed against this scope.
**What to do:** create a new budget scope or contact the operator. Not retryable against this scope.
### TENANT_CLOSED (409)
The owning tenant has been permanently closed. Every mutating admin-plane operation on any object owned by a closed tenant — budgets, reservations, API keys, webhook subscriptions, policies — is rejected with this code. GET endpoints remain available for post-mortem audit reads.
This error is issued by the **Rule 2 — Terminal-Owner Mutation Guard** half of the cascade contract (governance-admin spec v0.1.25.29, shipped in `cycles-server-admin` v0.1.25.35; full coverage v0.1.25.36). Rule 2's counterpart — **Rule 1 — Close Cascade** — runs at tenant-close time and automatically drives owned objects to terminal states (`BudgetLedger → CLOSED`, `ApiKey → REVOKED`, open reservations → `RELEASED`, `WebhookSubscription → DISABLED`), so by the time you see this error the owned objects are already terminal. There is no way to "undo" a close; this is not a race condition that will resolve on retry.
**On the runtime plane** (runtime spec v0.1.25.13, shipped in `cycles-server` 0.1.25.47): the persisting reservation mutations — create (`dry_run` absent or `false`), commit, release, extend — return `409 TENANT_CLOSED` once the owning tenant's `CLOSED` flip is durable. For non-replay mutations it takes precedence over the reservation-state errors (`RESERVATION_FINALIZED`, `RESERVATION_EXPIRED`); same-key replays of mutations that succeeded before the close return their original stored response. Fresh (non-replay) `dry_run=true` create and `POST /v1/decide` evaluations never 409 for this condition — they return `200` with `decision=DENY` and `reason_code=TENANT_CLOSED` (see [Decision reason codes](#decision-reason-codes)). A present-but-malformed tenant record (undecodable JSON, missing or unrecognized `status`) fails closed with `500 INTERNAL_ERROR` before any mutation; deployments with no tenant records at all are not guarded. A mutation-surface `409 TENANT_CLOSED` on the evidence endpoints (persisting create, commit, release) emits an `error` CyclesEvidence envelope and stamps `cycles_evidence` on the response. Note that the cascade also revokes the tenant's API keys and the runtime auth filter rejects CLOSED-tenant keys per request, so tenant-key calls usually fail with `401` first — the 409 surfaces mainly on admin-on-behalf-of **release** (the only guarded mutation the runtime plane exposes to `X-Admin-API-Key`; the admin dual-auth allowlist is reservation list/get/release, so create/commit/extend accept tenant keys only) and, for any of the four, in the post-flip/pre-revocation race window. `cycles-server` 0.1.25.46 and earlier surface closed tenants on the runtime plane only as `401`s (revoked/rejected keys) or budget-state errors such as `BUDGET_CLOSED`.
**What to do:** the tenant and its owned objects are read-only. Create a new tenant or escalate. **Not retryable against any object owned by this tenant** — unlike `BUDGET_FROZEN` (which an operator may unfreeze), `TENANT_CLOSED` is terminal. Implement no retry logic for this error.
**For client-app developers.** If your users encounter `TENANT_CLOSED`, escalate to your platform operator — they control tenant lifecycle; a client cannot un-close a tenant. New workloads require a fresh active tenant. To proactively detect a closed tenant and surface a friendlier message before mutation attempts, call `GET /v1/admin/tenants/{tenant_id}` with admin credentials. Runtime balance and reservation reads still work for closed tenants, but their resource status fields do not report the tenant lifecycle status.
**In bulk-action responses:** rows targeting a closed tenant go into the `failed[]` bucket with `error_code=TENANT_CLOSED`; the rest of the batch continues.
See [Tenant-Close Cascade Semantics](/protocol/tenant-close-cascade-semantics) for the full Rule 1 / Rule 2 contract, affected endpoints, and Mode A vs Mode B cascade behavior.
### RESERVATION_EXPIRED (410)
The reservation's TTL plus grace period has elapsed.
The reservation has been finalized as EXPIRED and its budget has been returned to the pool.
**What to do:** create a new reservation if the work still needs to proceed. If the work already completed, the usage may need to be recorded as an event instead.
### RESERVATION_FINALIZED (409)
An operation was attempted on a reservation that is already in a terminal state (COMMITTED or RELEASED).
This typically happens when trying to extend a reservation that has already been committed.
**What to do:** no action needed on the reservation. If the extend was meant to keep a different reservation alive, check the reservation ID.
### IDEMPOTENCY_MISMATCH (409)
The same idempotency key was used with a different request payload.
This means the client sent a request with an idempotency key that was already used for a different operation.
**What to do:** use a unique idempotency key for each distinct operation. If this is a legitimate retry, ensure the request payload matches the original exactly.
### UNIT_MISMATCH (400)
The unit in the request does not match any budget stored for the derived scopes, but at least one of those scopes has a budget in a different unit.
Returned on four operations:
1. **Reserve** — `estimate.unit` does not match any budget at the derived scopes (a budget exists in a different unit)
2. **Commit** — `actual.unit` differs from the reservation's `estimate.unit`
3. **Event** — `actual.unit` does not match the budget stored for the target scope
4. **Decide** — `estimate.unit` does not match any budget at the derived scopes. This is an exception to `/decide`'s general "return `decision=DENY` (200) without 4xx" pattern, which applies only to budget-state conditions (debt, overdraft, insufficient remaining), not request-validity errors like a wrong unit.
When the cause is a wrong unit (rather than the absence of any budget at the scope), the server populates the error response's `details` object with:
- `scope` — the canonical scope identifier where the mismatch was detected
- `requested_unit` — the unit supplied by the client
- `expected_units` — array of units for which a budget does exist at that scope
so clients can self-correct without a separate lookup. `NOT_FOUND (404)` (with a `"Budget not found for provided scope: ..."` message) is reserved for the case where the target scope has no budget in **any** unit.
**What to do:** switch the request to one of the units listed in `details.expected_units`, or create a budget in the requested unit via `POST /v1/admin/budgets`.
### OVERDRAFT_LIMIT_EXCEEDED (409)
Appears in two contexts:
1. **During commit:** when `overage_policy=ALLOW_WITH_OVERDRAFT` and `(current_debt + delta) > overdraft_limit`
2. **During reservation:** when the scope is in over-limit state (`is_over_limit=true`) due to prior concurrent commits pushing debt past the limit
**What to do:**
- if during commit: the debt limit has been reached. The work already happened. An operator needs to fund the scope.
- if during reservation: the scope is blocked. Wait for debt to be repaid, or escalate to an operator. The client should retry with exponential backoff.
### DEBT_OUTSTANDING (409)
A new reservation was attempted against a scope that has outstanding debt (debt > 0) and no overdraft limit configured (overdraft_limit is absent or 0).
When an `overdraft_limit > 0` is configured, debt within the limit does not block new reservations. Only scopes without an overdraft limit treat any debt as blocking.
**What to do:** wait for debt to be repaid through budget funding, or configure an overdraft limit if debt within a limit is acceptable. Retry with exponential backoff, or escalate to an operator.
Note: when `is_over_limit=true`, the server returns `OVERDRAFT_LIMIT_EXCEEDED` instead of `DEBT_OUTSTANDING`, even if debt > 0. `OVERDRAFT_LIMIT_EXCEEDED` takes precedence.
### MAX_EXTENSIONS_EXCEEDED (409)
The tenant's `max_reservation_extensions` limit has been reached for this reservation. No further extensions are allowed.
**What to do:** commit or release the reservation. If more time is needed, create a new reservation after committing the current one.
### LIMIT_EXCEEDED (429)
The client has exceeded the server's rate limit. Added in spec v0.1.25.12, mirroring the governance-plane code of the same name; the reference runtime server enforces it on the **public (unauthenticated)** endpoints — `GET /v1/evidence/*` and the CyclesEvidence JWKS — since v0.1.25.46 (default 300 requests/minute per client IP, per instance; see [Public endpoint rate limiting](/configuration/server-configuration-reference-for-cycles#public-endpoint-rate-limiting-v0-1-25-46)). Authenticated `/v1` endpoints are not rate limited by the reference server (abuse there is key-attributable).
The 429 response carries throttling headers alongside the standard correlation headers:
- `Retry-After` — seconds to wait before retrying
- `X-RateLimit-Reset` — when the current window resets
- `X-RateLimit-Remaining: 0`
**What to do:** wait for the `Retry-After` interval, then retry. If you hit this limit during legitimate operation, raise `CYCLES_PUBLIC_RATE_LIMIT_REQUESTS_PER_MINUTE` or rate-limit at your ingress instead.
### INTERNAL_ERROR (500)
An unexpected server error occurred.
Since `cycles-server` 0.1.25.47 this is also the deliberate fail-closed response when a tenant record exists but its status cannot be determined (undecodable JSON, non-object, missing or unrecognized `status`) — the closed-tenant guard refuses to treat a corrupt governance record as an open tenant, on the dry-run/decide surface too. That variant will not resolve on retry; the operator must repair the tenant record.
**What to do:** retry unexpected or transient internal errors with exponential backoff. If the error persists, contact the Cycles server operator. Do not keep retrying the malformed-tenant-record variant described above; it requires operator repair.
## Error handling by operation
### Reserve errors
| Error | HTTP | Meaning |
|---|---|---|
| BUDGET_EXCEEDED | 409 | Insufficient budget |
| BUDGET_FROZEN | 409 | Budget scope is frozen |
| BUDGET_CLOSED | 409 | Budget scope is permanently closed |
| TENANT_CLOSED | 409 | Owning tenant is closed (cycles-server 0.1.25.47+, spec v0.1.25.13). Persisting create only — a fresh `dry_run=true` returns `200 decision=DENY reason_code=TENANT_CLOSED` instead |
| OVERDRAFT_LIMIT_EXCEEDED | 409 | Scope is over-limit |
| DEBT_OUTSTANDING | 409 | Scope has unresolved debt (no overdraft limit configured) |
| IDEMPOTENCY_MISMATCH | 409 | Same key, different payload |
| NOT_FOUND | 404 | No budget exists at any derived scope in any unit (message: `"Budget not found for provided scope: ..."`) |
| UNIT_MISMATCH | 400 | `estimate.unit` does not match any budget at the derived scopes (budget exists in a different unit) |
| INVALID_REQUEST | 400 | Malformed request |
| UNAUTHORIZED | 401 | Invalid API key |
| FORBIDDEN | 403 | Tenant mismatch |
### Decide errors
| Error | HTTP | Meaning |
|---|---|---|
| UNIT_MISMATCH | 400 | `estimate.unit` does not match any budget at the derived scopes (budget exists in a different unit) |
| INVALID_REQUEST | 400 | Malformed request |
| UNAUTHORIZED | 401 | Invalid API key |
| FORBIDDEN | 403 | Tenant mismatch |
| IDEMPOTENCY_MISMATCH | 409 | Same key, different payload |
Note: decide returns `200` with `decision: DENY` for budget-state conditions (insufficient remaining, debt, overdraft, and the "no budget exists at any scope" case — surfaced via `reason_code` from the [DecisionReasonCode enum](#decision-reason-codes)), not a `409` or `404` error. The same holds for a closed owning tenant (cycles-server 0.1.25.47+, spec v0.1.25.13): a fresh (non-replay) evaluation returns `200 decision=DENY reason_code=TENANT_CLOSED`, never `409 TENANT_CLOSED` — though a present-but-malformed tenant record fails closed with `500 INTERNAL_ERROR`. Request-validity errors like `UNIT_MISMATCH` are still returned as 400. The same applies to `POST /v1/reservations` when `dry_run=true`.
### Commit errors
| Error | HTTP | Meaning |
|---|---|---|
| BUDGET_EXCEEDED | 409 | Actual exceeds budget (REJECT only) |
| BUDGET_FROZEN | 409 | Budget scope is frozen |
| BUDGET_CLOSED | 409 | Budget scope is permanently closed |
| TENANT_CLOSED | 409 | Owning tenant is closed (cycles-server 0.1.25.47+, spec v0.1.25.13); takes precedence over reservation-state errors for non-replay requests |
| OVERDRAFT_LIMIT_EXCEEDED | 409 | Debt would exceed limit (ALLOW_WITH_OVERDRAFT) |
| RESERVATION_EXPIRED | 410 | Past TTL + grace period |
| RESERVATION_FINALIZED | 409 | Already committed or released |
| UNIT_MISMATCH | 400 | Unit differs from reservation |
| NOT_FOUND | 404 | Reservation never existed |
| IDEMPOTENCY_MISMATCH | 409 | Same key, different payload |
| UNAUTHORIZED | 401 | Invalid API key |
| FORBIDDEN | 403 | Reservation owned by different tenant |
### Release errors
| Error | HTTP | Meaning |
|---|---|---|
| TENANT_CLOSED | 409 | Owning tenant is closed (cycles-server 0.1.25.47+, spec v0.1.25.13); takes precedence over reservation-state errors for non-replay requests |
| RESERVATION_EXPIRED | 410 | Past TTL + grace period |
| RESERVATION_FINALIZED | 409 | Already committed or released |
| IDEMPOTENCY_MISMATCH | 409 | Same key, different payload |
| NOT_FOUND | 404 | Reservation never existed |
| UNAUTHORIZED | 401 | Invalid API key |
| FORBIDDEN | 403 | Reservation owned by different tenant |
### Extend errors
| Error | HTTP | Meaning |
|---|---|---|
| INVALID_REQUEST | 400 | Missing or invalid fields |
| TENANT_CLOSED | 409 | Owning tenant is closed (cycles-server 0.1.25.47+, spec v0.1.25.13); takes precedence over reservation-state errors for non-replay requests |
| RESERVATION_EXPIRED | 410 | Past TTL (no grace period for extend) |
| RESERVATION_FINALIZED | 409 | Already committed or released |
| MAX_EXTENSIONS_EXCEEDED | 409 | Tenant max_reservation_extensions limit reached |
| IDEMPOTENCY_MISMATCH | 409 | Same key, different payload |
| NOT_FOUND | 404 | Reservation never existed |
| UNAUTHORIZED | 401 | Invalid API key |
| FORBIDDEN | 403 | Reservation owned by different tenant |
### Event errors
| Error | HTTP | Meaning |
|---|---|---|
| BUDGET_EXCEEDED | 409 | Insufficient budget (REJECT only) |
| BUDGET_FROZEN | 409 | Budget scope is frozen |
| BUDGET_CLOSED | 409 | Budget scope is permanently closed |
| TENANT_CLOSED | 409 | Owning tenant is closed (cycles-server 0.1.25.47+, spec v0.1.25.13); applies to fresh event requests because event creation is a persisting budget debit |
| OVERDRAFT_LIMIT_EXCEEDED | 409 | Debt would exceed limit (ALLOW_WITH_OVERDRAFT) |
| NOT_FOUND | 404 | No budget exists at any derived scope in any unit (message: `"Budget not found for provided scope: ..."`) |
| UNIT_MISMATCH | 400 | `actual.unit` does not match any budget at the target scope (budget exists in a different unit) |
| INVALID_REQUEST | 400 | Malformed request |
| UNAUTHORIZED | 401 | Invalid API key |
| FORBIDDEN | 403 | Tenant mismatch |
| IDEMPOTENCY_MISMATCH | 409 | Same key, different payload |
For a fresh `/v1/events` request observed after the tenant's `CLOSED` flip, the runtime returns `409 TENANT_CLOSED`. Event creation has no dry-run mode, so this condition never becomes a `200 decision=DENY` response. A same-key replay of an event that succeeded before the close returns its original stored `201` response, and a malformed or undeterminable tenant record fails closed with `500 INTERNAL_ERROR`. In normal operation the auth filter may return `401` first because the close cascade also revokes tenant API keys.
## Decision reason codes
Separately from the 4xx error code list, `POST /v1/decide` and `POST /v1/reservations` with `dry_run=true` may return `200 OK` with `decision: DENY` and a machine-readable `reason_code`. As of v0.1.25, `DecisionReasonCode` is an **open string** (was a closed enum in v0.1.24 and earlier — widened so future extension specs can add reason codes without a breaking protocol bump). Documented known values:
| reason_code | Meaning |
|---|---|
| `BUDGET_EXCEEDED` | Remaining amount insufficient on at least one derived scope (evaluated against the requested `estimate.amount`). |
| `BUDGET_FROZEN` | A derived scope has a budget in `FROZEN` status (operator-set, no mutations allowed). |
| `BUDGET_CLOSED` | A derived scope has a budget in `CLOSED` status (permanently closed). |
| `BUDGET_NOT_FOUND` | No budget exists at any derived scope in the requested unit. On non-dry reserve and `/v1/events` paths this same underlying condition surfaces as `HTTP 404` with `error=NOT_FOUND` instead. |
| `OVERDRAFT_LIMIT_EXCEEDED` | Either `debt + delta > overdraft_limit` on commit, OR the scope is in over-limit state (`is_over_limit=true`) and no new reservations are permitted until reconciled. |
| `DEBT_OUTSTANDING` | A derived scope has `debt > 0` and `overdraft_limit == 0` (no policy permits further debt accrual). |
| `TENANT_CLOSED` | The owning tenant's status is `CLOSED` (deployments with a governance plane; added in spec v0.1.25.13, emitted by cycles-server 0.1.25.47+ on fresh dry-run/decide evaluations). The persisting mutation surface reports the same condition as `HTTP 409` with `error=TENANT_CLOSED` instead — see [TENANT_CLOSED (409)](#tenant-closed-409). |
**Why this is a separate enum.** The 4xx error codes surface request-level failures in the `error` field. Decision reason codes surface budget-state outcomes in the `reason_code` field on successful HTTP responses. Some labels overlap (e.g. `BUDGET_EXCEEDED`) because the same underlying condition is reported differently depending on the endpoint: `/decide` and dry-run reserve surface it as a non-4xx DENY decision, while non-dry reserve surfaces it as a `409` error.
**Forward compatibility.** Because `DecisionReasonCode` is an open string (since v0.1.25), **clients MUST handle unknown values gracefully** — treat as DENY, log the raw string, do not crash on enum parsing. Known values above are stable; future values will always be additive (e.g., v0.1.26 extension specs may emit `ACTION_QUOTA_EXCEEDED`, `ACTION_KIND_DENIED`, `ACTION_KIND_NOT_ALLOWED`).
## Idempotency and error handling
Errors interact with idempotency in specific ways:
- **Successful replay:** if you retry a request with the same idempotency key and payload, you get the original successful response. The operation is not applied again.
- **Payload mismatch:** if you reuse a key with a different payload, you get `409 IDEMPOTENCY_MISMATCH`.
- **Failed original:** if the original request failed (e.g., BUDGET_EXCEEDED), retrying with the same key sends a fresh request. Idempotency only applies to successful operations.
## Correlation identifiers
Every error response carries two server-generated identifiers:
- **`request_id`** — unique per HTTP request. Useful for correlating errors with server logs, debugging with the Cycles operator, and tracking specific failures in client-side monitoring.
- **`trace_id`** — 32-hex W3C Trace Context identifier. OPTIONAL on the schema; populated by conformant v0.1.25.14+ runtime and v0.1.25.31+ admin servers. Scopes a logical operation that may span multiple HTTP requests. Also echoed in the `X-Cycles-Trace-Id` response header.
Log both when handling errors. `trace_id` is usually the right choice for cross-plane root-cause analysis:
```bash
# Find everything that happened under one trace
GET /v1/admin/audit/logs?trace_id=<32-hex>
GET /v1/admin/events?trace_id=<32-hex>
```
`request_id` narrows to the side effects of one specific HTTP call. See [Correlation and Tracing](/protocol/correlation-and-tracing-in-cycles) for the full contract.
## Summary
Cycles provides 17 specific error codes that tell the client exactly what went wrong:
- **400** for request validation issues (INVALID_REQUEST, UNIT_MISMATCH)
- **401** for authentication failures (UNAUTHORIZED)
- **403** for authorization failures (FORBIDDEN)
- **404** for missing resources (NOT_FOUND) — covers both missing reservations and missing budgets, distinguished by the `message` field
- **409** for budget and state conflicts (BUDGET_EXCEEDED, BUDGET_FROZEN, BUDGET_CLOSED, TENANT_CLOSED, OVERDRAFT_LIMIT_EXCEEDED, DEBT_OUTSTANDING, RESERVATION_FINALIZED, IDEMPOTENCY_MISMATCH, MAX_EXTENSIONS_EXCEEDED)
- **410** for expired reservations (RESERVATION_EXPIRED)
- **429** for rate limiting on public endpoints (LIMIT_EXCEEDED)
- **500** for server errors (INTERNAL_ERROR)
Additionally, `/v1/decide` and dry-run reserve surface budget-state conditions via a `reason_code` field on `200 DENY` responses rather than as 4xx errors. These values come from a separate [DecisionReasonCode](#decision-reason-codes) enum — distinct from the 4xx error code list.
Handling these codes correctly is the difference between a fragile integration and a production-grade one.
## Debugging with `trace_id`
When an error response carries `trace_id`, the fastest way to reconstruct the full operation is a three-call walk across the admin plane:
```bash
TID=
curl -s "http://localhost:7979/v1/admin/audit/logs?trace_id=$TID" -H "X-Admin-API-Key: $ADMIN_KEY"
curl -s "http://localhost:7979/v1/admin/events?trace_id=$TID" -H "X-Admin-API-Key: $ADMIN_KEY"
```
See [Correlation and Tracing](/protocol/correlation-and-tracing-in-cycles) for the full contract (W3C Trace Context precedence, outbound headers on webhook deliveries, cross-plane propagation rules).
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- [Correlation and Tracing](/protocol/correlation-and-tracing-in-cycles) — `trace_id` as the cross-plane join key for debugging
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
# Event Payloads Reference
This page documents the payload structure for every webhook event Cycles can emit. Each event wraps a standard envelope with an event-specific `data` object.
::: info Currently Emitted Events
The v0.1.25 Admin API `EventType` enum registers **51 event types** total across seven categories (budget: 17, reservation: 6, tenant: 6, api_key: 7, policy: 3, webhook: 7, system: 5). Events marked as **Planned** below have their type registered in the protocol but are not yet emitted by any service.
**Registered enum values currently emitted** (count toward the 51 total):
- **Reservation:** `reservation.denied`, `reservation.expired`, `reservation.commit_overage` (runtime).
- **Budget:** runtime emits `budget.exhausted`, `budget.over_limit_entered`, and `budget.debt_incurred`; admin emits `budget.created`, `budget.updated`, `budget.frozen`, `budget.unfrozen`, `budget.funded`, `budget.debited`, `budget.reset`, `budget.reset_spent`, and `budget.debt_repaid`; tenant close emits `budget.closed_via_tenant_cascade`.
- **Tenant:** `tenant.created`, `tenant.updated` (current admin implementation); `tenant.suspended`, `tenant.reactivated`, `tenant.closed` (admin v0.1.25.38+, single-op + bulk-action paths).
- **API key:** `api_key.created`, `api_key.revoked`, and `api_key.auth_failed` (admin v0.1.25+); `api_key.permissions_changed` (admin v0.1.25.7+).
- **Policy:** `policy.created` and `policy.updated` (admin v0.1.25+).
- **Webhook:** `webhook.created`, `webhook.updated`, `webhook.paused`, `webhook.resumed`, `webhook.deleted` (admin v0.1.25.39+); `webhook.disabled` (events service auto-disable v0.1.25.11+). All six webhook lifecycle types were added in spec v0.1.25.33 — see the [Webhook Lifecycle Events](#webhook-lifecycle-events) section below.
- **System:** `system.webhook_test` is sent directly by the admin webhook-test endpoint; `system.webhook_delivery_failed` is persisted by the events service after retry exhaustion (events v0.1.25.21+).
- **Tenant-close cascade fan-out:** `budget.closed_via_tenant_cascade`, `reservation.released_via_tenant_cascade`, `api_key.revoked_via_tenant_cascade`, `webhook.disabled_via_tenant_cascade` (admin v0.1.25.35+; declared in the governance spec's enum since revision v0.1.25.35) — see [Tenant-Close Cascade Events](#tenant-close-cascade-events-governance-spec-v0-1-25-35) below.
Registered values not named above remain planned unless a later section says otherwise.
**Historical additive runtime payloads** (documented in the v0.1.25.3 release history, but not present in the current runtime `EventType` model or emitted by current controller paths):
- Reservation lifecycle samples: `reservation.reserved`, `reservation.committed`, `reservation.released`, `reservation.extended`.
- Runtime ledger application: `event.applied`.
See the [Event Emission Summary](#event-emission-summary) at the bottom for the full per-category breakdown.
:::
## Standard Envelope
Every event shares this envelope structure. The `data` field varies by event type.
```json
{
"event_id": "evt_a1b2c3d4e5f67890",
"event_type": "budget.exhausted",
"category": "budget",
"timestamp": "2026-04-01T14:32:00.123Z",
"tenant_id": "acme-corp",
"scope": "tenant:acme-corp/workspace:prod",
"source": "cycles-server",
"actor": {
"type": "api_key",
"key_id": "key_abc123",
"source_ip": "10.0.1.50"
},
"data": { },
"correlation_id": "3f2a9c14e0b7d5a1",
"request_id": "req_789",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"metadata": {}
}
```
### Envelope fields
| Field | Type | Always present | Description |
|---|---|---|---|
| `event_id` | string | Yes | Unique event identifier (format: `evt_*`). Use for deduplication. |
| `event_type` | string | Yes | Dotted event name (e.g., `budget.exhausted`) |
| `category` | string | Yes | One of: `budget`, `reservation`, `tenant`, `api_key`, `policy`, `webhook`, `system` (the `webhook` value was added in spec v0.1.25.34) |
| `timestamp` | string | Yes | ISO 8601 UTC timestamp |
| `tenant_id` | string | Yes | Tenant ID (system events use `__system__`) |
| `scope` | string | When applicable | Full scope path (e.g., `tenant:acme-corp/workspace:prod`) |
| `source` | string | Yes | Emitting service: `cycles-server` (runtime events), `cycles-admin` (admin-plane events including bulk-action emits and webhook lifecycle events since v0.1.25.38/.39), or `cycles-events` (dispatcher-emitted `webhook.disabled` on auto-disable, v0.1.25.11). |
| `actor` | object | When applicable | Who triggered: `type` (`api_key`, `admin`, `system`, `scheduler`), `key_id`, `source_ip` |
| `data` | object | Varies | Event-specific payload (see below). Some events emit `null`. |
| `correlation_id` | string | When applicable | Server-managed family key. The YAML requires deterministic runtime event clusters, but the current reference runtime leaves this absent on implemented runtime emits. Selected admin operations populate explicit IDs such as `webhook_create:`, bulk-action IDs, and cascade IDs. |
| `request_id` | string | When provided | From `X-Request-Id` header on originating request |
| `trace_id` | string | When provided | W3C Trace Context-compatible correlation identifier (32 lowercase hex characters). Links the event to the originating request, its audit entry, and sibling events within the same logical operation. |
| `metadata` | object | When provided | Operator-defined key-value pairs |
---
## Reservation Events
### `reservation.reserved` — Historical Additive Payload (v0.1.25.3)
**Historical trigger:** A reservation was created successfully.
The current reference runtime does not emit this event. Query reservation state and keep application telemetry for successful reserve operations.
---
### `reservation.committed` — Historical Additive Payload (v0.1.25.3)
**Historical trigger:** A reservation was committed with actual spend recorded.
The current reference runtime does not emit this event. A commit that requests more than the estimate can emit `reservation.commit_overage`, and budget-state changes can emit the implemented budget events.
---
### `reservation.released` — Historical Additive Payload (v0.1.25.3)
**Historical trigger:** A reservation was cancelled.
The current reference runtime does not emit this event. An admin-on-behalf-of release does write its required audit entry; ordinary release state remains queryable through the reservation API.
---
### `reservation.extended` — Historical Additive Payload (v0.1.25.3)
**Historical trigger:** A reservation TTL was extended via heartbeat.
The current reference runtime does not emit this event. Query the reservation for current expiry state and log successful extensions in the application when needed.
---
### `reservation.denied` — Currently Emitted
**Trigger:** `POST /v1/decide` or a reservation request with `dry_run: true` returns `decision: DENY`.
**Emitted from:** `POST /v1/reservations` when the nonpersisting dry-run response is DENY, and `POST /v1/decide` when its response is DENY. A live reservation denial is an HTTP error such as `409 BUDGET_EXCEEDED`; the current controller does not emit `reservation.denied` for that exception path. Monitor `cycles_reservations_reserve_total{decision="DENY"}` or application errors for live denial rate.
```json
{
"event_type": "reservation.denied",
"data": {
"scope": "tenant:acme-corp/workspace:prod/workflow:support",
"unit": "USD_MICROCENTS",
"reason_code": "BUDGET_EXCEEDED",
"requested_amount": 500000,
"remaining": 100000,
"action": {
"kind": "llm.chat",
"name": "support-reply"
},
"subject": {
"tenant": "acme-corp",
"workspace": "prod",
"workflow": "support"
}
}
}
```
::: tip Fields populated at emission time
The governance schema defines 9 fields. The current server emitter populates `scope`, `unit`, `reason_code`, `requested_amount`, `action`, and `subject`; a denied reservation dry run also derives `remaining` from returned balances, while `/decide` currently omits `remaining`. `policy_id` and `deny_detail` remain unpopulated.
:::
| Field | Type | Populated | Description |
|---|---|---|---|
| `scope` | string | Yes | Scope path that denied the reservation |
| `reason_code` | string | Yes | Why denied. Known values: `BUDGET_EXCEEDED`, `OVERDRAFT_LIMIT_EXCEEDED`, `DEBT_OUTSTANDING`, `BUDGET_FROZEN`, `BUDGET_CLOSED`, and — from cycles-server 0.1.25.47 (spec v0.1.25.13) — `TENANT_CLOSED` on fresh dry-run/decide DENYs for a closed owning tenant. Open string — extensions (v0.1.26+) may emit additional values such as `ACTION_QUOTA_EXCEEDED`, `ACTION_KIND_DENIED`, `ACTION_KIND_NOT_ALLOWED`. |
| `requested_amount` | number | Yes | Amount the reservation requested |
| `unit` | string | Yes | Budget unit (`USD_MICROCENTS`, `TOKENS`, `CREDITS`, `RISK_POINTS`) |
| `remaining` | number | Dry-run reserve only | Minimum remaining amount derived from returned balances; omitted by the current `/decide` emitter |
| `action` | object | Yes | Action metadata from the evaluation request |
| `subject` | object | Yes | Subject metadata from the evaluation request |
| `policy_id` | string | Not yet | Policy ID that caused the denial, when applicable (added v0.1.25.8) |
| `deny_detail` | object | Not yet | Operator-grade structured context (added v0.1.25.8). Populated by extensions; may include `quota_violation`, `blocked_by_policy`, `blocked_by_scope`, `suggested_fix`, `budget_remaining`. |
---
### `reservation.commit_overage` — Currently Emitted
**Trigger:** A commit's actual cost exceeds the original reservation estimate.
**Emitted from:** `POST /v1/reservations/{id}/commit` (when `actual > estimated`)
```json
{
"event_type": "reservation.commit_overage",
"scope": "tenant:acme/workflow:support",
"data": {
"reservation_id": "res_a1b2c3d4",
"scope": "tenant:acme/workflow:support",
"unit": "USD_MICROCENTS",
"estimated_amount": 400000,
"actual_amount": 480000,
"overage": 80000,
"overage_policy": "ALLOW_IF_AVAILABLE",
"debt_incurred": 0
}
}
```
::: tip Fields populated at emission time
As of cycles-server v0.1.25.46, the emission populates **all 8** data fields and sets the envelope `scope` to the reservation's scope path — so `commit_overage` participates in scope filtering like any other scoped event. (Earlier releases populated only `reservation_id` and `actual_amount`, with a null envelope scope.)
:::
| Field | Type | Populated | Description |
|---|---|---|---|
| `reservation_id` | string | Yes | The reservation that exceeded its estimate |
| `scope` | string | Yes | Affected scope path (also set on the envelope) |
| `unit` | string | Yes | Budget unit |
| `estimated_amount` | number | Yes | Original reservation estimate |
| `actual_amount` | number | Yes | Actual cost committed |
| `overage` | number | Yes | Amount by which actual exceeded estimate |
| `overage_policy` | string | Yes | Policy applied: `REJECT`, `ALLOW_IF_AVAILABLE`, `ALLOW_WITH_OVERDRAFT` |
| `debt_incurred` | number | Yes | Debt created (0 unless `ALLOW_WITH_OVERDRAFT`) |
---
### `reservation.expired` — Currently Emitted
**Trigger:** A reservation TTL expires without being committed or released.
**Emitted from:** Background expiry sweeper (runs every 5 seconds by default)
```json
{
"event_type": "reservation.expired",
"data": {
"reservation_id": "res_d4e5f678",
"scope": "tenant:acme-corp/workspace:prod",
"unit": "USD_MICROCENTS",
"estimated_amount": 200000,
"created_at": "2026-04-01T14:30:00.000Z",
"expired_at": "2026-04-01T14:35:30.000Z",
"ttl_ms": 300000,
"extensions_used": 0
}
}
```
| Field | Type | Description |
|---|---|---|
| `reservation_id` | string | The expired reservation |
| `scope` | string | Affected scope path |
| `unit` | string | Budget unit |
| `estimated_amount` | number | Amount that was held by the reservation |
| `created_at` | string | When the reservation was created (ISO 8601) |
| `expired_at` | string | When the reservation expired (ISO 8601) |
| `ttl_ms` | number | Effective TTL in milliseconds (computed as `expired_at - created_at`; includes extensions) |
| `extensions_used` | number | How many times the reservation was extended before expiry |
---
### `reservation.denial_rate_spike` — Planned
**Trigger:** Denial rate exceeds configured threshold within a rolling window.
::: warning Not Yet Emitted
This event type is defined in the protocol but not yet emitted by the Cycles server. It will be implemented in a future release.
:::
---
### `reservation.expiry_rate_spike` — Planned
**Trigger:** Expiry rate exceeds configured threshold within a rolling window.
::: warning Not Yet Emitted
This event type is defined in the protocol but not yet emitted by the Cycles server. It will be implemented in a future release.
:::
---
## Budget Events
### Historical threshold aliases
Earlier v0.1.25.x builds documented additive `budget.approaching_limit`, `budget.at_limit`, and `budget.over_limit` aliases. They are not in the current runtime `EventType` model and the current `EventEmitterService` does not emit them. Use `budget.exhausted` for the implemented zero-remaining transition and calculate earlier utilization alerts from balances or metrics. The registered `budget.threshold_crossed` type remains unimplemented.
---
### `budget.reset_spent` — Currently Emitted (v0.1.25.18)
**Trigger:** An admin operator issues a `RESET_SPENT` funding operation on `POST /v1/admin/budgets/fund`.
**Emitted from:** `cycles-server-admin`. Distinct from `budget.reset` — `RESET` resizes the allocated ceiling and preserves `spent`; `RESET_SPENT` additionally clears (or overrides) `spent` for billing-period rollover.
The payload is an `EventDataBudgetLifecycle` with `spent` and `reserved` fields on `BudgetState`, plus an optional `spent_override_provided` boolean flag on the outer payload (`true` when the operator supplied an explicit `spent` value).
See [Rolling over billing periods with RESET_SPENT](/how-to/rolling-over-billing-periods-with-reset-spent) for operator guidance.
---
### `event.applied` — Historical Additive Payload (v0.1.25.3)
**Historical trigger:** A direct debit via `POST /v1/events` was applied successfully.
The current reference runtime does not emit `event.applied`. It returns the applied direct-debit response and can emit implemented budget-state events when that debit changes a ledger.
---
### `budget.exhausted` — Currently Emitted
**Trigger:** A budget's remaining amount transitions from above zero to zero after a reservation, commit, or direct debit.
**Emitted from:** `EventEmitterService.emitBalanceEvents()` (when pre-operation remaining is above zero and post-operation remaining is zero)
```json
{
"event_type": "budget.exhausted",
"data": {
"scope": "tenant:acme-corp/workspace:prod",
"unit": "USD_MICROCENTS",
"threshold": 1.0,
"utilization": 1.0,
"allocated": 10000000,
"remaining": 0,
"spent": 9000000,
"reserved": 1000000,
"direction": "rising"
}
}
```
| Field | Type | Description |
|---|---|---|
| `scope` | string | Affected scope path |
| `unit` | string | Budget unit |
| `threshold` | number | `1.0` for the implemented exhaustion transition |
| `utilization` | number | `(spent + reserved) / allocated` when allocated is positive |
| `allocated` | number | Current allocated amount |
| `remaining` | number | `0` for this event |
| `spent` | number | Current spent amount |
| `reserved` | number | Current reserved amount |
| `direction` | string | `rising` |
The envelope also identifies the tenant, actor, and request context. Query the balance API before remediation because later operations can change the ledger after the event is emitted.
---
### `budget.over_limit_entered` — Currently Emitted
**Trigger:** Debt exceeds the configured `overdraft_limit` on a budget with `ALLOW_WITH_OVERDRAFT` policy.
**Emitted from:** `EventEmitterService.emitBalanceEvents()` (when `is_over_limit` transitions to `true`)
```json
{
"event_type": "budget.over_limit_entered",
"data": {
"scope": "tenant:acme-corp/workspace:prod",
"unit": "USD_MICROCENTS",
"debt": 1500000,
"overdraft_limit": 1000000,
"is_over_limit": true,
"debt_utilization": 1.5
}
}
```
| Field | Type | Description |
|---|---|---|
| `scope` | string | Affected scope path |
| `unit` | string | Budget unit |
| `debt` | number | Current debt amount |
| `overdraft_limit` | number | Configured overdraft ceiling |
| `is_over_limit` | boolean | Always `true` for this event |
| `debt_utilization` | number | Ratio: `debt / overdraft_limit` |
---
### `budget.debt_incurred` — Currently Emitted
**Trigger:** A reservation commit or direct debit creates new debt via `ALLOW_WITH_OVERDRAFT`.
**Emitted from:** `EventEmitterService.emitBalanceEvents()` (when new debt is created)
```json
{
"event_type": "budget.debt_incurred",
"data": {
"scope": "tenant:acme-corp/workspace:prod",
"unit": "USD_MICROCENTS",
"reservation_id": "res_a1b2c3d4",
"debt_incurred": 250000,
"total_debt": 750000,
"overdraft_limit": 1000000,
"overage_policy": "ALLOW_WITH_OVERDRAFT"
}
}
```
| Field | Type | Description |
|---|---|---|
| `scope` | string | Affected scope path |
| `unit` | string | Budget unit |
| `reservation_id` | string | Reservation whose commit caused the debt; omitted for a direct debit |
| `debt_incurred` | number | New debt created on this scope by the operation |
| `total_debt` | number | Total accumulated debt on this scope |
| `overdraft_limit` | number | Configured overdraft ceiling |
| `overage_policy` | string | Policy applied (`ALLOW_WITH_OVERDRAFT`) |
---
### Planned Budget Events
The following registered budget events are not emitted by the current reference services:
| Event Type | Trigger |
|---|---|
| `budget.closed` | Budget permanently closed |
| `budget.threshold_crossed` | Utilization crossed configured threshold (e.g., 80%, 95%) |
| `budget.over_limit_exited` | Debt dropped below overdraft limit after repayment |
| `budget.burn_rate_anomaly` | Spend rate exceeds baseline multiplier within window |
---
## Tenant-Close Cascade Events (governance spec v0.1.25.35+)
Four event kinds are emitted by the reference admin server as side effects of a `* → CLOSED` tenant transition (Rule 1 — Close Cascade; see [Tenant-Close Cascade Semantics](/protocol/tenant-close-cascade-semantics) for the full contract). All four share the `_via_tenant_cascade` suffix and carry a server-composed `correlation_id` of the form `tenant_close_cascade::`, so subscribers can correlate cascade side effects to the operator action that triggered them (audit rows for the same operation join via `request_id`/`trace_id`).
These four event names are **declared in the governance spec's `EventType` enum** since document revision v0.1.25.35 (raising the registered enum to 51 values), so cascade Events validate against `Event.event_type` and can be targeted with `event_type=` filters and webhook `event_types` lists like any other lifecycle event. Note the normative strength: the per-object cascade **audit entries** are a MUST (their `event_kind` values are RESERVED in the spec), while emitting the corresponding Event-stream records is a SHOULD — so non-reference servers may not emit them, and consumers should still ignore unrecognized event types gracefully. Tenant self-service subscriptions filter by category, so a tenant subscribed to `budget` or `reservation` events will receive the corresponding cascade events from the reference server in practice.
Shipped in `cycles-server-admin` v0.1.25.35 (initial Mode B cascade) / v0.1.25.36 (full Rule 2 guard coverage).
All four kinds share one payload shape (`EventDataTenantCascade`, governance spec v0.1.25.35): exactly one of `ledger_id` / `subscription_id` / `key_id` identifies the transitioned object (matching the event's category), alongside `prior_status` / `new_status` and `cascade_reason: "tenant_closed"`. The reservation aggregate is the exception — it identifies the drained budget via `ledger_id` and carries `released_amount` instead of a status transition.
### `budget.closed_via_tenant_cascade`
Emitted once per owned `BudgetLedger` when the tenant closes. The per-budget `BudgetLedger.status` flips to `CLOSED` and `closed_at` is stamped; the final balance snapshot is preserved for audit.
```json
{
"event_id": "evt_...",
"event_type": "budget.closed_via_tenant_cascade",
"category": "budget",
"timestamp": "2026-04-20T12:00:00Z",
"tenant_id": "acme-corp",
"scope": "tenant:acme-corp/workspace:prod",
"source": "cycles-admin",
"actor": {
"type": "admin"
},
"data": {
"ledger_id": "led_...",
"scope": "tenant:acme-corp/workspace:prod",
"unit": "USD_MICROCENTS",
"prior_status": "ACTIVE",
"new_status": "CLOSED",
"cascade_reason": "tenant_closed"
},
"correlation_id": "tenant_close_cascade:acme-corp:req_...",
"trace_id": ""
}
```
### `reservation.released_via_tenant_cascade`
Emitted as a **ledger-level aggregate** when the tenant closes: one event per closed budget with `reserved > 0`, carrying the aggregate `released_amount` (not one per reservation). Reason `tenant_closed`; no overage debt is recorded; the full reserved amount returns to the (now-closed) budget's balance snapshot.
```json
{
"event_id": "evt_...",
"event_type": "reservation.released_via_tenant_cascade",
"category": "reservation",
"tenant_id": "acme-corp",
"data": {
"ledger_id": "led_...",
"scope": "tenant:acme-corp/workspace:prod",
"unit": "USD_MICROCENTS",
"released_amount": 250000,
"cascade_reason": "tenant_closed"
},
"correlation_id": "tenant_close_cascade:acme-corp:req_...",
"trace_id": ""
}
```
### `api_key.revoked_via_tenant_cascade`
Emitted once per owned `ApiKey` when the tenant closes. The per-key `ApiKey.status` flips to `REVOKED` and `revoked_at` is stamped.
```json
{
"event_id": "evt_...",
"event_type": "api_key.revoked_via_tenant_cascade",
"category": "api_key",
"tenant_id": "acme-corp",
"data": {
"key_id": "key_...",
"prior_status": "ACTIVE",
"new_status": "REVOKED",
"name": "production",
"cascade_reason": "tenant_closed"
},
"correlation_id": "tenant_close_cascade:acme-corp:req_...",
"trace_id": ""
}
```
### `webhook.disabled_via_tenant_cascade`
Emitted once per owned `WebhookSubscription` when the tenant closes. Status flips to `DISABLED`; re-enable is blocked by the Rule 2 guard (returns `409 TENANT_CLOSED`), making DISABLED effectively-terminal for closed-owner subscriptions without adding a new enum value.
```json
{
"event_id": "evt_...",
"event_type": "webhook.disabled_via_tenant_cascade",
"category": "webhook",
"tenant_id": "acme-corp",
"data": {
"subscription_id": "whsub_...",
"prior_status": "ACTIVE",
"new_status": "DISABLED",
"name": "ops-alerts",
"cascade_reason": "tenant_closed"
},
"correlation_id": "tenant_close_cascade:acme-corp:req_...",
"trace_id": ""
}
```
### Correlating cascade events
The shared `correlation_id` is the primary join key — querying `GET /v1/admin/events?correlation_id=...` returns every event emitted by the cascade in one call. The dashboard (v0.1.25.43+) renders a "tenant cascade" chip on audit and event-timeline rows with these suffixes. See [Using the Cycles Dashboard](/how-to/using-the-cycles-dashboard#closed-tenant-tombstone-and-cascade-preview).
**No emission-order guarantee.** The spec's ordering language covers the cascade's *mutations* (a SHOULD, and only within Mode A's single transaction — see [Tenant-Close Cascade Semantics](/protocol/tenant-close-cascade-semantics#the-two-rules)); it is silent on cascade *event emission* order. The reference implementation currently emits per budget — `budget.closed_via_tenant_cascade` then, for budgets with `reserved > 0`, the `reservation.released_via_tenant_cascade` aggregate, interleaved budget by budget — followed by webhook and API-key events, but this is implementation detail. Subscribers MUST NOT rely on arrival order (at-least-once webhook delivery can reorder and duplicate regardless — see [delivery mechanics](/protocol/webhook-event-delivery-protocol)); reconstruct the cascade by joining on the shared `correlation_id` instead.
---
## Webhook Lifecycle Events
**Currently emitted (spec v0.1.25.33).** Admin v0.1.25.39 emits six webhook lifecycle event types on the subscription CRUD + bulk-action paths; events v0.1.25.11 emits `webhook.disabled` on the dispatcher auto-disable path. All six share the `EventDataWebhookLifecycle` payload and the `webhook` category.
### `EventDataWebhookLifecycle` payload
| Field | Type | Always present | Description |
|---|---|---|---|
| `subscription_id` | string | Yes | The affected webhook subscription (`whsub_...`). |
| `tenant_id` | string | Yes | Owning tenant — mirrors the envelope for convenience. |
| `previous_status` | string | When applicable | `ACTIVE` / `PAUSED` / `DISABLED`. Absent on `webhook.created` (no prior state). Present on `webhook.deleted` (the status the subscription held before deletion). |
| `new_status` | string | When applicable | `ACTIVE` / `PAUSED` / `DISABLED`. Post-mutation status. Absent on `webhook.deleted` (subscription no longer exists). |
| `changed_fields` | array<string> | On `webhook.updated` | The subscription fields the PATCH actually modified (diff vs prior snapshot — identity-PATCHes emit an empty array and full-identity PATCHes suppress emit entirely per spec §6281). |
| `disable_reason` | string | On `webhook.disabled` | Why the dispatcher auto-disabled this subscription. Canonical value: `consecutive_failures_exceeded_threshold`. |
### `webhook.created`
**Trigger:** Successful `POST /v1/admin/webhooks`.
**Emitted by:** `cycles-server-admin` v0.1.25.39.
**Correlation-id shape:** `webhook_create:`.
```json
{
"event_id": "evt_...",
"event_type": "webhook.created",
"category": "webhook",
"tenant_id": "acme-corp",
"source": "cycles-admin",
"data": {
"subscription_id": "whsub_...",
"tenant_id": "acme-corp",
"new_status": "ACTIVE"
},
"correlation_id": "webhook_create:whsub_...",
"trace_id": "<32-hex>"
}
```
### `webhook.updated`
**Trigger:** `PATCH /v1/admin/webhooks/{id}` that is neither a pure `ACTIVE → PAUSED` nor `PAUSED → ACTIVE` flip (those emit `webhook.paused` / `webhook.resumed` instead).
**Emitted by:** `cycles-server-admin` v0.1.25.39.
**Correlation-id shape:** `webhook_update::`.
`changed_fields` is a true diff against the prior snapshot: re-PATCHing the same values is silently suppressed — no event emitted — so operators don't see lifecycle noise from identity writes.
### `webhook.paused`
**Trigger:** `PATCH /v1/admin/webhooks/{id}` with a status transition `ACTIVE → PAUSED`, or `POST /v1/admin/webhooks/bulk-action` with `action=PAUSE`.
**Emitted by:** `cycles-server-admin` v0.1.25.39.
**Correlation-id shape:** `webhook_update::` (single-op) or `webhook_bulk_action:pause:` (bulk).
### `webhook.resumed`
**Trigger:** `PATCH /v1/admin/webhooks/{id}` with a status transition `PAUSED → ACTIVE`, or `POST /v1/admin/webhooks/bulk-action` with `action=RESUME`.
**Emitted by:** `cycles-server-admin` v0.1.25.39.
**Correlation-id shape:** `webhook_update::` (single-op) or `webhook_bulk_action:resume:` (bulk).
### `webhook.disabled`
**Trigger:** The dispatcher auto-disables a subscription after consecutive delivery failures cross `disable_after_failures`.
**Emitted by:** `cycles-server-events` v0.1.25.11.
**Correlation-id shape:** `webhook_auto_disable::`.
**Actor:** `{type: system}` with `source = cycles-events`.
This is reserved for dispatcher-driven disables. Operator-initiated disables show up as `webhook.paused` (soft-disable) or `webhook.deleted` (removal). Tenant-close cascades use the separate `webhook.disabled_via_tenant_cascade` event — see [Tenant-Close Cascade Events](#webhook-disabled-via-tenant-cascade) above.
```json
{
"event_id": "evt_...",
"event_type": "webhook.disabled",
"category": "webhook",
"tenant_id": "acme-corp",
"source": "cycles-events",
"actor": { "type": "system" },
"data": {
"subscription_id": "whsub_...",
"tenant_id": "acme-corp",
"previous_status": "ACTIVE",
"new_status": "DISABLED",
"disable_reason": "consecutive_failures_exceeded_threshold"
},
"correlation_id": "webhook_auto_disable:whsub_...:dlv_...",
"trace_id": ""
}
```
### `webhook.deleted`
**Trigger:** Successful `DELETE /v1/admin/webhooks/{id}`, or `POST /v1/admin/webhooks/bulk-action` with `action=DELETE`.
**Emitted by:** `cycles-server-admin` v0.1.25.39.
**Correlation-id shape:** `webhook_delete:` (single-op) or `webhook_bulk_action:delete:` (bulk).
### Correlating webhook lifecycle events
Bulk-action invocations stamp every per-row emit with a shared `correlation_id` (`webhook_bulk_action::`) — query `GET /v1/admin/events?correlation_id=...` to pull every lifecycle event from one operator action. Skipped or failed rows never emit. See [Using Bulk Actions](/how-to/using-bulk-actions-for-tenants-and-webhooks) for the full bulk-action event contract.
For the managing-webhooks operator flow (subscription creation, signing-secret rotation, delivery health) see [Managing Webhooks](/how-to/managing-webhooks).
---
## Tenant, API Key, Policy, and System Events
Current services emit part of every category below. The tables distinguish direct lifecycle emission, synthetic test delivery, and values that remain registered-but-planned.
### Tenant Events (6 types — 5 currently emitted)
| Event Type | Status | Trigger |
|---|---|---|
| `tenant.created` | **Emitted** (current admin implementation) | New tenant provisioned |
| `tenant.updated` | **Emitted** (current admin implementation) | Tenant configuration changed |
| `tenant.suspended` | **Emitted** (admin v0.1.25.38+) | `PATCH /v1/admin/tenants/{id}` or `bulk-action` sets status to `SUSPENDED` |
| `tenant.reactivated` | **Emitted** (admin v0.1.25.38+) | `PATCH /v1/admin/tenants/{id}` or `bulk-action` restores status to `ACTIVE` |
| `tenant.closed` | **Emitted** (admin v0.1.25.38+) | `PATCH /v1/admin/tenants/{id}` or `bulk-action` sets status to `CLOSED` — also triggers the four `_via_tenant_cascade` events documented above |
| `tenant.settings_changed` | Planned | Tenant default settings modified |
### API Key Events (6 base types, plus the tenant-cascade event)
| Event Type | Status | Trigger |
|---|---|---|
| `api_key.created` | **Emitted** (admin v0.1.25+) | New API key generated |
| `api_key.revoked` | **Emitted** (admin v0.1.25+) | API key permanently revoked |
| `api_key.expired` | Planned | API key reached expiration date |
| `api_key.permissions_changed` | **Emitted** (admin v0.1.25.7+) | API key permissions modified |
| `api_key.auth_failed` | **Emitted** (admin v0.1.25+) | Authentication attempt failed |
| `api_key.auth_failure_rate_spike` | Planned | Auth failure rate exceeded threshold |
`api_key.revoked_via_tenant_cascade` is emitted separately by admin v0.1.25.35+ when tenant close revokes owned keys.
### Policy Events (3 types — 2 currently emitted)
| Event Type | Status | Trigger |
|---|---|---|
| `policy.created` | **Emitted** (admin v0.1.25+) | New policy rule created |
| `policy.updated` | **Emitted** (admin v0.1.25+) | Policy configuration changed |
| `policy.deleted` | Planned | Policy removed |
### System Events (5 types — 2 currently produced)
| Event Type | Status | Trigger |
|---|---|---|
| `system.store_connection_lost` | Planned | Redis connection failed |
| `system.store_connection_restored` | Planned | Redis connection recovered |
| `system.high_latency` | Planned | Server-side p99 latency exceeded threshold |
| `system.webhook_delivery_failed` | **Emitted** (events v0.1.25.21+) | Webhook delivery permanently failed after all retries; persisted as a loop-safe meta-event rather than recursively delivered |
| `system.webhook_test` | **Sent directly** (current admin implementation) | Admin-initiated synthetic connectivity test; not queued as an ordinary stored event |
---
## Event Emission Summary
| Category | Total Defined | Currently Emitted | Notes |
|---|---|---|---|
| Reservation | 6 | `reservation.denied`, `reservation.expired`, and `reservation.commit_overage` emitted by runtime paths; the cascade aggregate (`reservation.released_via_tenant_cascade`, spec-declared since governance v0.1.25.35) emitted by the admin server on tenant close | Spike events still planned |
| Budget | 17 | Runtime exhaustion/over-limit/debt events; admin create/update/freeze/unfreeze/funding events; `budget.closed_via_tenant_cascade` on tenant close | `budget.closed`, `budget.threshold_crossed`, `budget.over_limit_exited`, and `budget.burn_rate_anomaly` are not emitted |
| Tenant | 6 | `tenant.created`, `tenant.updated`, `tenant.suspended`, `tenant.reactivated`, and `tenant.closed` emitted by the current admin service | `tenant.settings_changed` still planned |
| API Key | 7 | `api_key.created`, `.revoked`, `.permissions_changed`, `.auth_failed`, plus `.revoked_via_tenant_cascade` | Expiry and rate-spike events still planned |
| Policy | 3 | `policy.created`, `policy.updated` | `policy.deleted` still planned |
| Webhook | 7 | 6 lifecycle events (`webhook.created` / `.updated` / `.paused` / `.resumed` / `.disabled` / `.deleted`) from admin v0.1.25.39 + events v0.1.25.11, plus `webhook.disabled_via_tenant_cascade` (spec-declared since v0.1.25.35) on tenant close | All registered enum values emitted |
| System | 5 | `system.webhook_delivery_failed` persisted by the events service; `system.webhook_test` delivered directly by the admin test endpoint | Store-connection and high-latency events still planned |
| **Total** | **51** | See category rows above | — |
For webhook delivery mechanics, retry schedule, and signature verification, see the [Webhook Event Delivery Protocol](/protocol/webhook-event-delivery-protocol).
For integration examples (PagerDuty, Slack, ServiceNow), see [Webhook Integrations](/how-to/webhook-integrations).
# How Decide Works in Cycles: Preflight Budget Checks Without Reservation
Sometimes a system needs to know whether an action would be allowed before committing to it.
Not to reserve budget.
Not to begin execution.
Just to ask: **is there room?**
That is what the decide endpoint does.
## What decide is
`POST /v1/decide` evaluates a budget request against current scope balances and returns a decision — without creating a reservation or modifying any budget state.
It is a read-only preflight check.
The response tells you:
- **ALLOW** — sufficient budget exists
- **ALLOW_WITH_CAPS** — sufficient budget exists, but soft constraints apply
- **DENY** — insufficient budget or policy block
No budget is held. No reservation is created. No commit or release is needed afterward.
## When to use decide
Decide is useful when the system needs budget awareness without budget commitment.
### 1. UI gating
Before showing an action button to a user, the frontend can check whether the action would be allowed under current budget.
If the answer is DENY, the UI can disable the button, show a warning, or suggest a cheaper alternative.
### 2. Planning and routing
An agent or orchestrator may need to decide between multiple possible actions based on budget availability.
Decide lets the system evaluate options without locking budget for all of them.
### 3. Pre-validation before expensive setup
Some workflows require setup steps before the actual model or tool call. Decide lets the system check budget feasibility before investing in that setup.
### 4. Soft-landing signals
The decide endpoint can return caps (via ALLOW_WITH_CAPS) that signal the system should adjust its behavior — use fewer tokens, avoid certain tools, or slow down — without denying execution outright.
### 5. Dashboard and operator visibility
Operators can use decide to evaluate hypothetical scenarios against live budget state without affecting production accounting.
## How decide works in the protocol
A decide request includes four required fields:
- **subject** — the six standard fields that derive budget scopes (tenant, workspace, app, workflow, agent, toolset), plus optional `dimensions` metadata that does not derive scopes in the reference server
- **action** — the proposed action (kind, name, optional tags)
- **estimate** — the amount the action would need
- **idempotency_key** — for request deduplication
An optional `metadata` map can be attached for application-level context.
The server evaluates the request against current balances and returns:
- **decision** — ALLOW, ALLOW_WITH_CAPS, or DENY
- **caps** — soft constraints (only present when decision is ALLOW_WITH_CAPS)
- **reason_code** — machine-readable reason when decision is DENY (`DecisionReasonCode`, see below)
- **retry_after_ms** — optional guidance on when to retry
- **affected_scopes** — which scopes were evaluated
- **cycles_evidence** — a reference to the signed CyclesEvidence envelope emitted for this decision (artifact type `decide`; present on ALLOW, ALLOW_WITH_CAPS, and DENY outcomes, absent only when evidence emission is disabled on the server)
Only `decision` is required in the response — every other field is conditional or optional.
`DecisionReasonCode` is an **open string** (not a closed enum) with the following documented known values:
| reason_code | Meaning |
|---|---|
| `BUDGET_EXCEEDED` | Remaining amount insufficient on at least one derived scope |
| `BUDGET_FROZEN` | A derived scope has a budget in `FROZEN` status |
| `BUDGET_CLOSED` | A derived scope has a budget in `CLOSED` status |
| `BUDGET_NOT_FOUND` | No budget exists at any derived scope in the requested unit (on non-dry reserve and `/v1/events`, this same condition surfaces as `HTTP 404` with `error=NOT_FOUND`) |
| `OVERDRAFT_LIMIT_EXCEEDED` | Either `debt + delta > overdraft_limit`, or the scope is in over-limit state (`is_over_limit=true`) |
| `DEBT_OUTSTANDING` | A derived scope has `debt > 0` and `overdraft_limit == 0` |
| `TENANT_CLOSED` | The owning tenant's status is `CLOSED` (deployments with a governance plane; added in spec v0.1.25.13, emitted by cycles-server 0.1.25.47+ on fresh evaluations — the persisting reservation mutations surface the same condition as `HTTP 409` with `error=TENANT_CLOSED`) |
`DecisionReasonCode` was widened from a closed enum to an open string in v0.1.25 so future extension specs can add new reason codes without a breaking protocol bump. **Clients MUST handle unknown values gracefully** (treat as DENY, log the raw string, do not crash on enum parsing). Known values above are stable; future values will always be additive. See [Decision reason codes](/protocol/error-codes-and-error-handling-in-cycles#decision-reason-codes) for full semantics.
The v0.1.26 runtime extension adds three action-governance reason codes that can surface on decide denials:
| reason_code | Meaning |
|---|---|
| `ACTION_QUOTA_EXCEEDED` | A per-kind or risk-class action quota rule was exceeded for the target scope and window |
| `ACTION_KIND_DENIED` | The action kind is in the matching policy's `denied_action_kinds` list |
| `ACTION_KIND_NOT_ALLOWED` | The matching policy has a non-empty `allowed_action_kinds` list and the action kind is not in it |
See `cycles-protocol-extensions-v0.1.26.yaml` for full semantics, evaluation order, and the `DenyDetail` structure populated alongside `reason_code` for these denials.
## Decide does not guarantee future reservation
An important subtlety: decide is a point-in-time evaluation.
If decide returns ALLOW at time T, a subsequent reservation at time T+1 may still fail if concurrent activity consumed budget in between.
Decide is advisory. It reflects current state, not a promise about future state.
For guaranteed budget holds, use reservations.
## Decide and debt/overdraft
When a scope has outstanding debt or is in over-limit state, the server SHOULD return `DENY` with the appropriate reason code (`DEBT_OUTSTANDING` or `OVERDRAFT_LIMIT_EXCEEDED`). This is a SHOULD, not a MUST — but the server MUST NOT return `409` for these conditions on decide.
For budget-state conditions — debt, overdraft, insufficient remaining — decide returns a `200` response with a decision value rather than a 4xx error. The exception is request-validity errors: if the estimate's unit doesn't match any budget at the derived scopes (but at least one of those scopes has a budget in a different unit), decide returns `400 UNIT_MISMATCH` just like the other endpoints.
This makes decide safe to call in any context without needing error handling for budget state issues.
## Decide vs dry_run
Both decide and `dry_run: true` on a reservation request evaluate budget without modifying state. But they serve different purposes:
- **decide** is a lightweight check that returns a decision and optional caps
- **dry_run** evaluates the full reservation path including scope derivation, affected scopes, and balance snapshots
Use decide for quick feasibility checks. Use dry_run for full shadow-mode evaluation of reservation logic.
## A practical example
An agent is planning its next step. It has two options:
1. Call a large model (estimated 8,000 tokens)
2. Call a smaller model (estimated 2,000 tokens)
Before choosing, the agent calls decide for each option:
- Option 1: decide returns DENY (run budget is too low)
- Option 2: decide returns ALLOW
The agent routes to the smaller model without ever creating a reservation for the larger one.
This avoids wasting budget on reservation overhead for paths that would be denied.
## When not to use decide
Decide is not a substitute for reservations.
If the system needs:
- guaranteed budget holds before execution
- concurrency-safe budget enforcement
- commit/release lifecycle tracking
then create a reservation instead.
Decide is for asking questions. Reservations are for taking action.
## Summary
Decide provides a read-only preflight check against live budget state.
It helps systems:
- gate actions before committing to them
- route between alternatives based on budget availability
- check feasibility without locking budget
- receive soft-landing signals via caps
It is advisory, not binding. For guaranteed budget holds, use reservations.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
# How Events Work in Cycles: Direct Debit Without Reservation
The core Cycles lifecycle is reserve → execute → commit.
That pattern works well when the system can estimate exposure before execution, hold bounded room, and reconcile actual usage afterward.
But not every action fits that pattern.
Sometimes the cost is already known.
Sometimes the work already happened.
Sometimes reservation overhead is not justified.
Sometimes the system is recording usage from an external source that Cycles does not control.
That is where events come in.
## What events are
An event in Cycles is a direct debit against a budget scope — without creating a reservation first.
The API endpoint is `POST /v1/events`.
Instead of:
1. reserve
2. execute
3. commit
an event does:
1. record actual usage directly
The server applies the usage atomically across all derived scopes, just like a commit would, but without the preceding reservation step.
## When to use events
Events are useful when:
- the cost is already known before the call
- the action already completed outside of Cycles
- reservation overhead is not justified for the action class
- the system is importing historical or external usage into the budget ledger
- the action is low-risk and does not need pre-execution budget authorization
### Examples
- recording a model call that already happened through an external gateway
- importing usage from a billing provider into Cycles for unified budget tracking
- logging a known-cost action like sending an email or creating a ticket
- accounting for background work that was not instrumented with reserve-commit
- migrating from a legacy usage system into Cycles
## When not to use events
Events should not replace reservations for actions where pre-execution budget control matters.
If the system needs to:
- decide whether work is allowed before it runs
- hold bounded room before expensive execution
- protect against concurrent over-consumption
- enforce run-level or workflow-level budget ceilings proactively
then reserve → commit is the right pattern.
Events are post-hoc accounting. Reservations are pre-execution control.
Both are useful. They solve different problems.
## How events work in the protocol
An event request includes:
- **subject** — the six standard fields that derive budget scopes (tenant, workspace, app, workflow, agent, toolset), plus optional `dimensions` metadata that does not derive scopes in the reference server
- **action** — what happened (kind, name, optional tags)
- **actual** — the amount consumed (unit and amount)
- **idempotency_key** — ensures the same event is not recorded twice
- **overage_policy** — what happens if budget is insufficient (REJECT, ALLOW_IF_AVAILABLE, or ALLOW_WITH_OVERDRAFT)
- **metrics** — optional operational metadata (tokens_input, tokens_output, latency_ms, model_version, custom)
- **client_time_ms** — optional client-observed timestamp (advisory only, not used for budget enforcement)
- **metadata** — optional arbitrary key-value metadata for audit or debugging
The server applies the charge atomically across all derived scopes, or rejects the entire event.
On success, the server returns `201` — the event has been created and atomically applied to balances before the response is sent. The response includes:
- `status: APPLIED`
- `event_id` — a unique identifier for the event
- `charged` — the amount actually applied (optional; present when `ALLOW_IF_AVAILABLE` caps the charge to remaining budget, so the client can see the effective charge)
- `balances` — updated balance state for affected scopes
### Error cases
Beyond the `409` overage rejections described below, the spec defines these error responses for `POST /v1/events`:
- `400 UNIT_MISMATCH` — the actual's unit doesn't match the budget stored for the target scope (a budget exists at the scope, but in a different unit)
- `404 NOT_FOUND` — no budget exists at any of the event's derived scopes in *any* unit; the message field carries the specific `"Budget not found for provided scope: ..."` detail
- `403 FORBIDDEN` — the request's `subject.tenant` doesn't match the effective tenant derived from auth
- `409 IDEMPOTENCY_MISMATCH` — the same idempotency key was reused with a different payload (see Idempotency below)
## Overage policies on events
Events support the same three overage policies as commits:
### REJECT
If the actual amount exceeds the available budget, the event is rejected with `409 BUDGET_EXCEEDED`.
REJECT prevents any accounting that would put the scope into negative remaining.
### ALLOW_IF_AVAILABLE (default)
If sufficient budget remains across all affected scopes, the full actual amount is applied atomically. If any scope has insufficient remaining, the charge is capped to available remaining (the minimum across all derived scopes, floor 0) and `is_over_limit` is set to `true` only on the scopes where the full amount could not be covered.
ALLOW_IF_AVAILABLE is the default when the request does not specify a policy. It never creates debt and never rejects an event.
### ALLOW_WITH_OVERDRAFT
If budget is insufficient, the system creates debt up to the scope's overdraft limit. If debt would exceed the overdraft limit, the event is rejected with `409 OVERDRAFT_LIMIT_EXCEEDED`.
This is useful for ensuring the ledger always reflects reality, even when budget is tight.
## Idempotency
Events are idempotent.
If the same `idempotency_key` is sent again with the same payload, the server returns the original response without applying the charge a second time.
If the same key is sent with a different payload, the server returns `409 IDEMPOTENCY_MISMATCH`.
This makes events safe under retries.
## Events vs reservations
| | Reservations | Events |
|---|---|---|
| Pre-execution control | Yes | No |
| Budget held before work | Yes | No |
| Commit/release lifecycle | Yes | No |
| Idempotent | Yes | Yes |
| Scope derivation | Yes | Yes |
| Overage policies | Yes | Yes |
| Best for | Actions with uncertain cost | Actions with known cost |
## A practical example
Suppose an external gateway processes a model call and reports back that it consumed 4,200 tokens.
The system can record this in Cycles with a single event:
- subject: tenant `acme`, app `support-bot`
- action: kind `llm.completion`, name `openai:gpt-4o-mini`
- actual: 4200 TOKENS
- overage_policy: ALLOW_IF_AVAILABLE
The server applies this charge across all derived scopes (tenant, app) and returns the updated balances.
No reservation was needed because the work already happened and the cost is already known.
## When to combine events with reservations
Many systems use both patterns.
For example:
- **reservations** for model calls and tool invocations that the system controls directly
- **events** for external usage imports, historical data, or low-cost background work
This gives the system pre-execution control where it matters most, and simple accounting everywhere else.
## Summary
Events provide a way to record known usage directly against budget scopes without the overhead of a reservation lifecycle.
They are useful for:
- known-cost actions
- external usage imports
- retroactive accounting
- low-risk actions where reservation is not justified
They support the same overage policies, idempotency, and scope derivation as reservations.
The key difference: reservations authorize work before it happens. Events record work after it happens.
Both are part of a complete Cycles deployment.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
# How Reserve → Commit Works in Cycles
Most systems discover usage after work is already done.
A model call finishes.
A tool runs.
A database is written.
A provider bill updates later.
A dashboard shows the damage after the fact.
That is observability.
It is not control.
Cycles uses a different model:
1. **Reserve exposure before execution**
2. **Execute the work**
3. **Commit actual usage or release the remainder**
This is the core execution pattern in Cycles.
It is what makes budget control work in systems with retries, loops, concurrency, and long-running workflows.
## Why reserve first?
In autonomous systems, cost and side effects are often created by software that keeps acting after the initial request is gone.
An agent may:
- call multiple models
- invoke tools recursively
- retry on failure
- fan out into parallel steps
- continue in the background
If you only measure usage after these actions complete, you are not really governing execution.
You are auditing it after the fact.
Reservation changes that.
Before work starts, the system asks:
::: info
Is this action allowed to consume up to this amount of exposure?
:::
If the answer is no, the action can be denied, downgraded, deferred, or rerouted before cost or side effects occur.
## The basic lifecycle
At a high level, Cycles follows this pattern:
### 1. Declare intent
An action identifies the scope and the estimated amount of exposure it may need.
That may correspond to:
- a model call
- a tool invocation
- a workflow step
- an agent run
- any other governable action
### 2. Reserve budget
The runtime attempts to reserve that amount against the relevant budget scopes.
If reservation succeeds, the action is allowed to proceed.
If reservation fails, the system can stop or degrade the action before execution.
### 3. Execute
The work runs.
At this point, the system knows it is operating within a bounded allowance.
### 4. Commit actual usage or release
Once the real usage is known, the runtime commits the actual amount consumed.
If the actual amount is lower than the reserved amount, the unused portion is released automatically as part of the commit. No separate release call is needed.
If work is canceled or fails before any usage occurs, the runtime releases the reservation explicitly, returning the full reserved amount to the budget pool.
::: tip SDK behavior
The official Python `@cycles`, TypeScript `withCycles`, Spring `@Cycles`, and Rust `ReservationGuard` helpers automate this lifecycle. When actual usage is known, current releases persist settlement before the first commit request, reuse the same idempotency key after ambiguous outcomes, and recover an expired commit through `POST /v1/events`. See [SDK Settlement Recovery and Durability](/protocol/sdk-settlement-recovery-and-durability).
:::
## A simple example
Suppose an agent wants to run a tool-assisted task and estimates it may consume 100 units.
The lifecycle looks like this:
1. Reserve 100
2. Execute the task
3. Actual usage ends up being 63
4. Commit 63 (the remaining 37 is released automatically)
Without this model, many systems do one of two bad things:
- they block based on rough static quotas
- they allow execution freely and reconcile later
Cycles allows bounded execution with reconciliation afterward.
## Why commit matters
Reservation alone is not enough.
If you reserve budget and never reconcile actual usage, your system becomes inaccurate very quickly.
You need commit because estimated cost and actual cost are often different.
For example:
- a model call returns sooner than expected
- a tool path exits early
- a workflow skips downstream actions
- a retry path consumes more than the optimistic estimate
Commit turns reserved intent into actual accounted usage.
That keeps balances meaningful.
## Why release matters
Release is just as important as commit.
If you reserve exposure and do not return unused portions, the system slowly accumulates phantom consumption.
That causes two problems:
- budgets appear tighter than they really are
- future work is denied unnecessarily
When actual usage is committed, the protocol automatically releases the unused remainder. When work is canceled or fails before any usage occurs, the runtime must explicitly release the reservation.
Explicit release is especially important for:
- canceled jobs
- partially completed workflows where no usage should be recorded
- guarded speculative execution
- early exits
- timeout handling
## Reserve → commit is different from post-hoc usage tracking
A lot of systems already track usage.
That is not the same thing.
Post-hoc tracking says:
::: info
We can tell you what happened after execution.
:::
Reserve → commit says:
::: info
We decide whether work may proceed before execution, then reconcile actual usage afterward.
:::
That difference is the entire point.
The first model supports reporting.
The second supports governance.
## Reserve → commit is different from flat quotas
Flat quotas are useful, but limited.
A quota might say:
- tenant A can spend 10,000 units today
- workflow B can use 500 units per run
That helps at the policy level.
But execution still needs a transactional pattern.
Reserve → commit provides that operational layer.
It answers questions like:
- can this action proceed right now?
- how much room has already been set aside?
- how do we avoid double counting?
- what happens if execution fails halfway through?
- how do we reconcile estimates with actuals?
## Why this matters under retries
Retries are one of the main reasons Cycles exists.
In real systems, retries happen because of:
- transient provider errors
- network instability
- worker crashes
- downstream timeouts
- optimistic retry logic
Without careful accounting, retries often create double-spend or hidden overages.
A reserve → commit model, combined with idempotency, gives the runtime a way to keep retries safe.
Instead of each retry being treated as brand-new spend, the system can reason about the same execution lifecycle consistently.
That is how you move from “best effort cost control” to deterministic budget enforcement.
## Why this matters under concurrency
Concurrency makes naive accounting unreliable.
Two workers can both believe budget is available.
Two steps can race.
A parent workflow and child tasks can all consume at once.
If accounting is not designed for this, you get inconsistent enforcement, accidental overages, or brittle locking.
Cycles is built around reservation semantics so the system can make budget decisions before work proceeds, rather than discovering collisions only after usage has already occurred.
## Hierarchical reservation
In Cycles, reservation is not limited to one flat counter.
An action may need to satisfy multiple scopes at once, such as:
- tenant
- workspace
- app
- workflow
- agent
- toolset
That means a reservation may need to be valid not only locally, but also against ancestor scopes.
This is important because real systems often need both:
- a global budget boundary
- more specific per-run or per-agent boundaries
Reserve → commit works cleanly with hierarchical governance because the runtime can check and enforce multiple levels before execution begins.
## What happens when reservation fails?
A failed reservation is not just a denial event.
It is a policy decision point.
Depending on the system, failure may result in:
- hard stop
- retry later
- downgrade to a smaller model
- disable expensive tools
- switch to read-only behavior
- reduce concurrency
- require operator approval
This is another reason reserve → commit is powerful.
It creates a place to make a bounded decision before irreversible work begins.
## What happens when work crashes?
Real workflows fail halfway through.
A worker may crash after reservation but before commit.
A process may be terminated.
A network partition may delay reconciliation.
A usable budget system has to handle these cases explicitly.
That is why Cycles treats reserve, commit, and release as separate lifecycle events rather than assuming execution is always clean and synchronous.
Current SDK lifecycle helpers durably preserve settlement once actual usage is known. They write the unresolved settlement before its first commit request, replay ambiguous outcomes with the original idempotency key, and use a direct event if the reservation expires before commit can land.
That guarantee has a hard boundary: if the process dies before the downstream operation returns an actual amount, the SDK has no amount to recover. Applications that require convergence across that boundary must durably checkpoint provider receipts or actual usage before acknowledging the downstream operation.
## Mental model
A simple way to think about it is:
- **Reserve** = hold bounded room to act
- **Commit** = record what was actually consumed (auto-releases unused remainder)
- **Release** = cancel the reservation and return what was held
That is the core accounting discipline.
Without reserve, budgets are advisory.
Without commit, estimates drift from reality.
Without release, unused allocations become silent waste.
## Why this model matters
Reserve → commit is not extra ceremony.
It is the difference between:
- hoping autonomous systems stay within budget
- and making budget enforcement part of execution itself
As systems become more autonomous, they need more than request throttling and usage dashboards.
They need a runtime model that can:
- authorize bounded work before execution
- keep accounting stable under retries
- reconcile actual usage afterward
- enforce policy across scopes
- degrade safely when budgets are tight
That is what Cycles provides.
## Next steps
To see where this model is defined and implemented:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
# How Scope Derivation Works in Cycles
Budget enforcement in Cycles is hierarchical.
A single action does not just charge one counter. It may need to satisfy limits at multiple levels — tenant, workspace, app, workflow, agent, toolset — all at once.
That is what makes Cycles different from flat usage counters.
But how does the server know which budget scopes to check and charge?
That is scope derivation.
## What scope derivation is
Scope derivation is the process by which the server transforms a Subject (a bag of dimension fields) into an ordered set of canonical scope identifiers.
Derivation happens wherever a Subject is submitted: reserve (`POST /v1/reservations`), decide (`POST /v1/decide`), and event (`POST /v1/events`). When one of those requests is processed, the server derives every scope that needs to be affected. Commit and release do not take a Subject — they operate on the scopes already derived at reserve time.
For example, given a Subject with:
- tenant: `acme`
- app: `support-bot`
- workflow: `refund-assistant`
The server derives these scopes (in canonical order):
1. `tenant:acme`
2. `tenant:acme/app:support-bot`
3. `tenant:acme/app:support-bot/workflow:refund-assistant`
Only explicitly provided levels are included — `workspace` is not present in the subject, so it is skipped. Each of these scopes is a separate budget boundary. A reservation is enforced at every derived scope that has a budget defined — at least one scope must have a budget.
## The canonical hierarchy
The Cycles protocol defines a fixed ordering for Subject fields:
```
tenant → workspace → app → workflow → agent → toolset
```
This ordering is normative. The server always processes fields in this order, and `affected_scopes` in responses are always returned in this canonical order.
## Gap-skipping
Not every Subject includes all six fields. When a field is missing from the hierarchy, the server skips it — only explicitly provided levels appear in the scope path.
For example, given:
- tenant: `acme`
- agent: `summarizer-v2`
The derived scope path is:
```
tenant:acme/agent:summarizer-v2
```
And the derived scopes are:
1. `tenant:acme`
2. `tenant:acme/agent:summarizer-v2`
Intermediate levels (`workspace`, `app`, `workflow`) are not present in the subject and are not filled with "default". This means operators only need to create budgets at levels they actually use, rather than at every intermediate level in the hierarchy.
Scopes without budgets are skipped during enforcement — at least one derived scope must have a budget defined.
## Field value charset
Standard-field values (tenant, workspace, app, workflow, agent, toolset) SHOULD match `^[a-zA-Z0-9_.-]+$` (normative as of the 2026-07-03 spec revision).
Canonical scope identifiers and scope paths use `:` and `/` as structural delimiters, so delimiter, whitespace, and control characters have no stable canonical encoding.
Servers MAY reject out-of-pattern values with `400 INVALID_REQUEST` — the reference implementation does. Portable clients SHOULD restrict themselves to the pattern above and MUST NOT rely on out-of-pattern values being accepted.
## Why hierarchical scopes matter
Hierarchical scopes enable layered budget governance.
### Tenant-level protection
The tenant scope ensures that all actions under a tenant count against the tenant's total budget.
No matter which app, workflow, or agent is running, the tenant boundary is always checked.
### App-level isolation
Different applications under the same tenant can have different budgets.
A support bot and a research agent can each have their own budget envelope, preventing one from consuming the other's allocation.
### Workflow-level control
Within an app, different workflows can have different cost profiles.
A refund workflow may justify more budget than a simple FAQ response.
### Agent-level boundaries
Within a workflow, individual agents can be bounded separately.
A planning agent and an execution agent can each have their own limits.
### Toolset-level restrictions
Within an agent, different toolsets can be governed independently.
Web search tools may have a different budget than database query tools.
## Atomic reservation across scopes
When a reservation is created, budget is reserved atomically across all derived scopes.
If any scope has insufficient budget, the reservation fails entirely. There is no partial reservation.
This prevents situations where a reservation succeeds at the local level but violates a parent boundary.
For example, if a workflow has budget remaining but the tenant is exhausted, the live reservation is rejected at the tenant scope.
## affected_scopes in responses
Reservation and decision responses include an `affected_scopes` field listing all scopes that were charged (or would be charged), in canonical order.
Event responses do not include `affected_scopes` — for `POST /v1/events`, read the charged scopes from the `scope_path` on each entry in the response's `balances` array instead.
Where present, `affected_scopes` tells the client exactly which budget boundaries were affected, which is useful for:
- debugging denial reasons
- understanding which scope is the bottleneck
- monitoring budget pressure across the hierarchy
## scope_path
The `scope_path` field in responses is the full canonical path for the reservation.
For example: `tenant:acme/app:support-bot/workflow:refund-assistant`
This uniquely identifies the leaf scope in the hierarchy.
## Custom dimensions
The Subject also supports a `dimensions` field for custom key-value pairs.
For example:
```json
{
"tenant": "acme",
"app": "support-bot",
"dimensions": {
"cost_center": "engineering",
"run": "run-12345"
}
}
```
In v0, servers MAY ignore dimensions for budgeting decisions entirely (the reference server does — scopes derive only from the six standard fields), but they must accept and round-trip the data.
Dimensions have hard limits: at most 16 keys, and each value at most 256 characters. Keys SHOULD be lowercase and match `^[a-z0-9_.-]+$` for stable canonicalization; values are opaque strings.
Dimensions never replace the standard fields. A Subject containing only `dimensions` — with no tenant, workspace, app, workflow, agent, or toolset — is invalid, and the server MUST return `400 INVALID_REQUEST`.
Dimensions are not budget-scope fields — they serve enterprise taxonomies, attribution/reporting, and policy uses (the v0.1.26 action-quota preview keys its `per_run` window off `dimensions.run_id`). To give each run an enforceable budget, encode the run id in a Subject field (e.g. `workflow: "run-{id}"`, deriving the scope `workflow:run-{id}`) — see [modeling tenant, workflow, and run budgets](/how-to/how-to-model-tenant-workflow-and-run-budgets-in-cycles).
## Scope derivation and balances
When querying balances (`GET /v1/balances`), the same scope hierarchy applies. At least one standard subject filter (tenant, workspace, app, workflow, agent, toolset) must be provided.
Returning child scopes is opt-in: pass `include_children=true` (the default is `false`). Be aware that `include_children` MAY be ignored by v0 implementations, so do not depend on child rows being present.
This gives operators visibility into budget state at any level of the hierarchy.
## Practical implications
### Budget allocation flows top-down
Tenant budgets constrain everything beneath them. If the tenant is exhausted, no child scope can reserve budget, even if the child has its own allocation.
### Pressure signals flow bottom-up
When a workflow or agent scope runs low, that pressure is visible at higher levels through balance queries and denial signals.
### Scope design is a policy decision
The scopes you populate on each Subject determine which budget boundaries are checked.
If you only provide tenant and workflow, the server derives scopes for tenant and workflow — intermediate levels are skipped. Agent and toolset scopes are not checked.
This means scope design is part of policy design. More scopes mean finer-grained control but more configuration.
## Summary
Scope derivation transforms Subject fields into a hierarchical set of budget boundaries:
- The canonical order is: tenant → workspace → app → workflow → agent → toolset
- Missing fields are skipped (not filled with "default")
- Reservations are atomic across all derived scopes
- Balances, affected_scopes, and scope_path all follow the same hierarchy
- Custom dimensions carry additional taxonomy and attribution metadata but do not derive budget scopes in the reference server
Understanding scope derivation is essential for designing effective budget policy in Cycles.
## Next steps
- [Tenants, Scopes, and Budgets](/how-to/understanding-tenants-scopes-and-budgets-in-cycles) — how the three building blocks fit together
- [Budget Allocation and Management](/how-to/budget-allocation-and-management-in-cycles) — create budgets at each scope level
- [Common Budget Patterns](/how-to/common-budget-patterns) — practical scope hierarchy recipes
- [Tenant, Workflow, and Run Budgets](/how-to/how-to-model-tenant-workflow-and-run-budgets-in-cycles) — multi-level policy design
- [Scope Misconfiguration and Budget Leaks](/incidents/scope-misconfiguration-and-budget-leaks) — what can go wrong with scope design
# Implement the Cycles Protocol
The Cycles Protocol is open. The reference server ([`runcycles/cycles-server`](https://github.com/runcycles/cycles-server)) is Apache 2.0 and validated against the spec, but it is not the only conformant implementation possible. Anyone can build a server that speaks the same wire format.
This page is the entry point for that work.
## Who this is for
- **Framework maintainers** — LangGraph, Temporal, agent runtimes, orchestrators that want first-class runtime budget authority without proxying every call to an external service.
- **Infrastructure teams** — operating an internal budget system already (custom rate limiter, cost tracker, action governance) and wanting Cycles-compatible client interop without replacing what works.
- **OSS contributors** — building adapters, alternative servers in other languages, or instrumented variants for specific deployment models (FaaS, edge, embedded).
- **Vendors with overlapping scope** — observability, cost tracking, identity governance vendors who want to add reserve-commit semantics to their existing platform.
If "should this specific next agent action proceed, given everything already consumed?" is a question your platform needs to answer, the protocol is the wire format for that question.
## Why a separate implementation might make sense
The reference Cycles server covers the canonical case: self-hosted, Java/Spring Boot, Redis-backed, multi-tenant. Reasons to implement separately:
- **Bespoke runtime requirements** — your stack already runs on Postgres, FoundationDB, DynamoDB, or an in-process state engine; you want budget authority co-located.
- **Integration with existing budget systems** — you've shipped a quota service for traditional API limits and want to expose it under the Cycles wire format so AI-agent SDKs work transparently.
- **Language / platform constraints** — embedded systems, FaaS edge runtimes, mobile / on-device agents where Java + Redis isn't viable.
- **Principled reasons** — you want a second independent implementation in the wild for protocol robustness (the OpenTelemetry multi-implementation pattern).
Cycles' reference server is fine; *protocol robustness* comes from multiple implementations testing the same wire format against the same conformance target — and, once published, the same conformance test kit.
## The minimum implementation surface
The authoritative statement of what's required lives in [`CONFORMANCE.md`](https://github.com/runcycles/cycles-protocol/blob/main/CONFORMANCE.md) — read that first. The current target requires a small core runtime + a cross-plane set, plus a smaller recommended set that well-rounded servers ship. Below is the surface at time of writing; if `CONFORMANCE.md` and this page disagree, **the spec wins**.
### Core runtime (MUST)
From [`cycles-protocol-v0.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/cycles-protocol-v0.yaml):
| Operation | Endpoint | Purpose |
|---|---|---|
| **createReservation** | `POST /v1/reservations` | Atomically lock budget across all affected scopes before action |
| **commitReservation** | `POST /v1/reservations/{reservation_id}/commit` | Settle the actual cost; release the unused portion |
| **releaseReservation** | `POST /v1/reservations/{reservation_id}/release` | Release the full reservation without spending (cancel) |
| **extendReservation** | `POST /v1/reservations/{reservation_id}/extend` | TTL heartbeat for long-running operations |
### Cross-plane (MUST)
From [`cycles-governance-admin-v0.1.25.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/cycles-governance-admin-v0.1.25.yaml). Much of the admin CRUD surface (tenant / budget / policy / key) is reference-shape rather than current conformance target, but the cross-plane operations below carry explicit `x-conformance: normative` labels and MUST follow the spec contract:
1. `GET /v1/admin/events` — **listEvents**
2. `GET /v1/admin/events/{event_id}` — **getEvent**
3. `POST /v1/admin/webhooks/{subscription_id}/replay` — **replayEvents**
4. `GET /v1/events` — **listTenantEvents** (tenant-scoped)
5. `GET /v1/admin/webhooks/{subscription_id}/deliveries` — **listWebhookDeliveries**
6. `GET /v1/webhooks/{subscription_id}/deliveries` — **listTenantWebhookDeliveries** (tenant-scoped)
7. `GET /v1/balances` — **getBalances** (admin-plane view)
8. `GET /v1/auth/introspect` — **introspectAuth**
### Recommended (SHOULD)
Well-rounded servers also implement these (from `cycles-protocol-v0.yaml`):
- `decide` — preflight budget check without reservation
- `listReservations` / `getReservation` — recovery and inspection
- `createEvent` — direct-debit event submission
You can ship without these and still claim conformance against the current target, but most clients expect them.
## Client recovery conformance
Server conformance is only one half of failure-safe settlement. SDK lifecycle helpers also need a shared contract for what happens when connectivity is lost after an action, the process restarts, a reservation expires before commit, or a heartbeat cannot be extended.
The authoritative [`client-recovery/PROFILE.md`](https://github.com/runcycles/cycles-protocol/blob/main/client-recovery/PROFILE.md) defines that client-side guarantee independently from the wire protocol. A durable-conformant SDK:
- persists known-actual settlement before the first request;
- reuses the same idempotency key after ambiguous outcomes;
- retains unresolved records across retry exhaustion, authentication failure, and restart;
- converts an expired commit into a same-key `POST /v1/events` settlement;
- exposes a bounded flush/drain operation; and
- makes heartbeat failure observable without suppressing final settlement.
The shared [`client-recovery/scenarios.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/client-recovery/scenarios.yaml) catalog is executable. SDKs claiming durable recovery must bind every applicable scenario to native behavior tests and run the profile runner in CI. See [SDK Settlement Recovery and Durability](/protocol/sdk-settlement-recovery-and-durability) for the operational guarantee and configuration of the four official SDKs.
## The four core invariants
Spec compliance isn't just endpoint coverage — it's behavior under the hood. Per `CONFORMANCE.md`:
1. **Atomic reservation across scopes** — when a reservation locks multiple affected scopes, it locks all of them or none. No partial locks.
2. **Concurrency-safe enforcement** — shared budgets MUST NOT be oversubscribed under concurrent reserve calls. The reference server uses Lua-scripted Redis operations; alternative implementations need equivalent atomicity guarantees in their backing store.
3. **Idempotent commit and release** — every commit / release MUST be safe to retry. The same action MUST NOT settle twice. Idempotency keys carry the contract.
4. **Unit consistency** — reservations and commits MUST validate and preserve unit denomination (USD_MICROCENTS, TOKENS, CREDITS, RISK_POINTS). Cross-unit operations MUST return `UNIT_MISMATCH` (400).
These are the contracts client SDKs and downstream systems rely on. An implementation that returns the right HTTP codes but lets concurrent reservations oversubscribe is non-conformant in spirit even if it passes a naive endpoint test.
## Error semantics
Implementations MUST return the exact HTTP status + `error` code pairs from `cycles-protocol-v0.yaml` §ERROR SEMANTICS. The full set:
- `BUDGET_EXCEEDED` — 409
- `OVERDRAFT_LIMIT_EXCEEDED` — 409
- `IDEMPOTENCY_MISMATCH` — 409
- `RESERVATION_FINALIZED` — 409
- `RESERVATION_EXPIRED` — 410
- `UNIT_MISMATCH` — 400
- `NOT_FOUND` — 404
- `DEBT_OUTSTANDING` — 409
- `LIMIT_EXCEEDED` — 429 (server-side throttling / rate limiting, optional in v0 and never used for deterministic budget exhaustion; added to the ErrorCode enum in spec v0.1.25.12, revision 2026-07-04). 429 responses carry the `Retry-After` and `X-RateLimit-Reset` headers.
- `TENANT_CLOSED` — 409 (deployments with a governance plane only; added to the ErrorCode enum in spec v0.1.25.13, revision 2026-07-10, mirroring the governance code of the same name). Persisting reservation create/commit/release/extend MUST reject with it when the owning tenant's status is CLOSED and the flip is durable, taking precedence over `RESERVATION_FINALIZED`/`RESERVATION_EXPIRED` for non-replay attempts. Fresh `dry_run=true` and `/v1/decide` evaluations MUST NOT 409 for this — they return `200 decision=DENY reason_code=TENANT_CLOSED`; a present-but-malformed tenant record MUST fail closed with 500 `INTERNAL_ERROR`. Deployments with no tenant records anywhere are exempt (nothing to enforce).
One `RESERVATION_EXPIRED` subtlety, clarified in the 2026-07-03 spec revision: `GET /v1/reservations/{reservation_id}` MUST return 410 `RESERVATION_EXPIRED` when the reservation exists but its status is EXPIRED — the pre-revision text enumerated only commit/release/extend, leaving the GET case ambiguous. EXPIRED reservations remain discoverable via `listReservations`, which returns them as normal 200 rows with `status=EXPIRED`; the 410 applies only to the single-resource GET.
Clients route on these codes — returning a generic 500 or a custom error string breaks the protocol contract even if the underlying behavior is correct.
Action-governance error codes (`ACTION_QUOTA_EXCEEDED`, `ACTION_KIND_NOT_ALLOWED`, `ACTION_KIND_DENIED`) are documented in upcoming spec extensions; check `CONFORMANCE.md` for whether they're currently MUST or SHOULD against the active target.
## Authentication and tenancy
Authenticate via the `X-Cycles-API-Key` header, per [`cycles-protocol-v0.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/cycles-protocol-v0.yaml) §AUTH & TENANCY. How API keys are provisioned, rotated, or scoped to permission sets is implementation-specific — but tenant isolation MUST be enforced.
Two exceptions are deliberately public (`security: []`): `GET /v1/evidence/{evidence_id}`, where the unguessable sha256 `evidence_id` acts as a capability, and `GET /v1/.well-known/cycles-jwks.json`, which serves the public verification-key set that is itself the trust anchor. Servers SHOULD rate-limit both (they declare 429 responses with `Retry-After` / `X-RateLimit-Reset`).
Three reservation operations additionally accept admin dual auth via `AdminKeyAuth` (`X-Admin-API-Key` header, the same header the governance-admin spec uses): `listReservations` (`GET /v1/reservations`), `getReservation` (`GET /v1/reservations/{reservation_id}`), and `releaseReservation` (`POST /v1/reservations/{reservation_id}/release`) — the operator use case is inspecting and force-releasing reservations across tenants, with the admin-driven release audited as `actor_type=admin_on_behalf_of`.
The reference server uses a permissions model with named scopes (`reservations:create`, `balances:read`, `admin:write`, etc.). Alternative implementations can use any equivalent authorization model as long as tenant isolation holds.
## Reference points
When you're stuck on a spec question, these are the canonical sources:
- **[`CONFORMANCE.md`](https://github.com/runcycles/cycles-protocol/blob/main/CONFORMANCE.md)** — the authoritative MUST / SHOULD / MAY document
- **[`cycles-protocol-v0.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/cycles-protocol-v0.yaml)** — runtime base spec
- **[`cycles-spec-index.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/cycles-spec-index.yaml)** — index of all spec files with conformance metadata
- **[`client-recovery/PROFILE.md`](https://github.com/runcycles/cycles-protocol/blob/main/client-recovery/PROFILE.md)** — client-side durable settlement and heartbeat-failure requirements
- **[`client-recovery/scenarios.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/client-recovery/scenarios.yaml)** — executable recovery behavior catalog
- **[Reference server source](https://github.com/runcycles/cycles-server)** — the Java/Spring Boot reference implementation. Read it for "how does the reference handle X edge case?"
- **[Protocol reference pages](/protocol/api-reference-for-the-cycles-protocol)** — narrative documentation of the same surface, useful for understanding the design intent behind each operation
- **[`.spectral.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/.spectral.yaml)** — OpenAPI linting config for keeping spec changes consistent
## Get help
The protocol is small but has subtle behavioral requirements. If you're implementing and have a spec question, edge case, or suspect a contradiction:
- **Email a maintainer** — [founder@runcycles.io](mailto:founder@runcycles.io) for protocol design / clarification questions
- **Open a spec issue** — [`runcycles/cycles-protocol/issues`](https://github.com/runcycles/cycles-protocol/issues) for ambiguity, contradiction, or proposed clarifications
- **Open a docs issue** — [`runcycles/cycles-docs/issues`](https://github.com/runcycles/cycles-docs/issues) for documentation gaps
- **Reference implementation issues** — [`runcycles/cycles-server/issues`](https://github.com/runcycles/cycles-server/issues) for behavior that disagrees with the spec
A founder reads every email and issue.
## Why this matters
Protocol > tool.
Every framework, vendor, and platform that implements the Cycles Protocol speaks the same wire format. Client SDKs work against any conformant server. Operators can switch implementations without touching application code. The category — runtime budget authority over AI agents — has a single, open, version-stable contract that transcends any individual product.
OpenTelemetry won observability by being the protocol every vendor implemented. The protocol that becomes the shared implementation contract shapes the category — and it only earns that role when multiple implementations validate the same wire format against the same conformance target, with a shared conformance kit to follow.
If you build a Cycles-compatible server, you make the protocol stronger. That's the work.
## Related
- [Cycles Protocol overview](/protocol/) — the hub page
- [API Reference](/protocol/api-reference-for-the-cycles-protocol) — narrative API docs
- [How Reserve / Commit Works](/protocol/how-reserve-commit-works-in-cycles) — the lifecycle everything else builds on
- [Runtime Authority vs Runtime Authorization](/concepts/runtime-authority-vs-runtime-authorization) — how the protocol fits alongside identity-based agent governance
# Cycles Protocol
The Cycles Protocol is an open specification for **runtime budget authority over AI agents**.
> **Authorization grants access; authority meters and bounds the action.**
The protocol defines how budgets are reserved before execution, committed after, and reconciled across hierarchical scopes — atomically, with three-way decisions and observable events. Cycles is the reference implementation; anyone can implement a conformant server.
## At a glance
- **Open specification** — Apache 2.0, multiple OpenAPI YAMLs in [`runcycles/cycles-protocol`](https://github.com/runcycles/cycles-protocol).
- **Explicit conformance criteria** — current MUST / SHOULD / MAY breakdown lives in [`CONFORMANCE.md`](https://github.com/runcycles/cycles-protocol/blob/main/CONFORMANCE.md). Start there for the authoritative surface against the current conformance target.
- **Reserve-commit lifecycle** — atomic budget locking before action, commit on completion, release on cancel, with TTL heartbeat for long-running operations.
- **Hierarchical scopes** — tenant → workspace → app → workflow → agent → toolset, evaluated atomically in one operation (run-level budgets are modeled by encoding the run id in a Subject field such as `workflow:run-{id}` — `dimensions` are metadata and never derive scopes).
- **Three-way decisions** — `ALLOW` / `ALLOW_WITH_CAPS` / `DENY`. Implementations return constraints (`max_tokens`, `tool_denylist`, `max_steps_remaining`) that let the agent self-regulate, not just stop.
- **Concurrency-safe enforcement** — shared budgets MUST NOT be oversubscribed under concurrent reserve calls.
- **Idempotent commit and release** — retries are safe; the same action MUST NOT settle twice.
- **Explicit error semantics** — `BUDGET_EXCEEDED` (409), `IDEMPOTENCY_MISMATCH` (409), `RESERVATION_EXPIRED` (410), `UNIT_MISMATCH` (400), and the rest defined in the spec.
- **Action-governance preview** — v0.1.26 YAMLs define action kinds, action quotas, allow/deny lists, observe mode, and quota-counter APIs. See [Action Governance Preview](/protocol/action-governance-preview-in-cycles).
- **Multiple language clients** — Python, TypeScript, Rust, Spring Boot, MCP host.
## Specification
The full specification lives in the [`runcycles/cycles-protocol`](https://github.com/runcycles/cycles-protocol) repository. Key files:
| File | Purpose |
|---|---|
| [`cycles-protocol-v0.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/cycles-protocol-v0.yaml) | Runtime base — reserve / commit / release / decide / balances / events |
| [`cycles-governance-admin-v0.1.25.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/cycles-governance-admin-v0.1.25.yaml) | Cross-plane events, webhooks, balances, auth introspection (mixed conformance) |
| [`cycles-action-kinds-v0.1.26.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/cycles-action-kinds-v0.1.26.yaml) | Action-kind registry + quota primitives (upcoming, SHOULD today) |
| [`cycles-protocol-extensions-v0.1.26.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/cycles-protocol-extensions-v0.1.26.yaml) | DenyDetail, ObserveMode, v0.1.26 evaluation order (upcoming) |
| [`cycles-governance-extensions-v0.1.26.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/cycles-governance-extensions-v0.1.26.yaml) | Action-quota / access-control policy fields (upcoming) |
| [`cycles-spec-index.yaml`](https://github.com/runcycles/cycles-protocol/blob/main/cycles-spec-index.yaml) | Index of all spec files with conformance metadata |
| [`CHANGELOG.md`](https://github.com/runcycles/cycles-protocol/blob/main/CHANGELOG.md) | Versioned change history |
For the human-readable v0.1.26 extension walkthrough, see [Action Governance Preview](/protocol/action-governance-preview-in-cycles). The YAML files remain authoritative.
## Conformance
The authoritative statement of what an implementation MUST, SHOULD, and MAY do is [`CONFORMANCE.md`](https://github.com/runcycles/cycles-protocol/blob/main/CONFORMANCE.md) in the spec repo. Read it first — the current conformance target, exact required-operation list, and any version-bumped requirements all live there. At a glance, the surface includes:
- **Core runtime operations** — reserve / commit / release / extend (atomic budget locking and lifecycle)
- **Cross-plane operations** — event listing, webhook delivery / replay, balance queries, auth introspection
- **Recommended operations** — `decide`, `listReservations`, `getReservation`, `createEvent`
- **4 core invariants** — atomic reservation across scopes, concurrency-safe enforcement, idempotent commit/release, unit consistency
- **Exact HTTP status + error code pairs** — implementations MUST return the spec's error codes verbatim so clients can route on them
- **`X-Cycles-API-Key` header** for authentication; key provisioning and scoping is implementation-specific
Reading the conformance doc is the prerequisite for any new implementation. RFC 2119 language throughout.
## Reference implementation
[`runcycles/cycles-server`](https://github.com/runcycles/cycles-server) is the reference implementation — Java / Spring Boot, Apache 2.0, validated against the current conformance target. The companion [`runcycles/cycles-server-admin`](https://github.com/runcycles/cycles-server-admin) provides the management plane (tenant / budget / policy / key CRUD).
Client SDKs that speak the protocol:
- **Python** — [`runcycles` PyPI package](/quickstart/getting-started-with-the-python-client)
- **TypeScript** — [`runcycles` npm package](/quickstart/getting-started-with-the-typescript-client)
- **Spring Boot** — [`cycles-spring-boot-starter`](/quickstart/getting-started-with-the-cycles-spring-boot-starter)
- **Rust** — [Rust client](/quickstart/getting-started-with-the-rust-client)
- **MCP host** — [MCP server](/quickstart/getting-started-with-the-mcp-server) for Claude / Cursor / Windsurf
All clients communicate over the same wire protocol. A conformant alternative server can replace the reference implementation transparently.
## Implement the Cycles Protocol
If you're building a framework, an in-house budget system, or an alternative server that should speak the same wire format as Cycles, see **[Implement the Cycles Protocol](/protocol/implement)** for the minimum implementation surface and conformance walkthrough.
## Protocol reference
The reference pages in the sidebar walk through every primitive in the protocol — reserve-commit lifecycle, scope derivation, units, caps and three-way decisions, overage policies, TTL and grace, decide preflight, dry-run / shadow mode, action-governance preview, events and direct debit, debt and overdraft, balance queries, reservation recovery, metrics and metadata, error codes, webhook event delivery, event payload schemas, scope filter syntax, correlation and tracing, tenant-close cascade semantics.
Start with [API Reference](/protocol/api-reference-for-the-cycles-protocol) for the operation surface, or [How Reserve / Commit Works](/protocol/how-reserve-commit-works-in-cycles) for the lifecycle that everything else builds on.
## Why a protocol, not just a tool
OpenTelemetry didn't win observability by being a tool — it won by being a protocol that every observability vendor implemented. For runtime budget authority on AI agents, the same dynamic applies: the team that owns the protocol owns the category. Cycles ships the spec, the conformance criteria, and the reference implementation in the open. Anyone can implement; everyone speaks the same wire format.
## Related
- [Runtime Authority vs Runtime Authorization](/concepts/runtime-authority-vs-runtime-authorization) — how the protocol fits alongside identity-based agent governance (AWS Bedrock AgentCore Policy, Akeyless)
- [Comparisons](/concepts/comparisons) — how Cycles differs from LiteLLM, Helicone, LangSmith, rate limiters, provider caps, DIY wrappers
- [What Is Runtime Authority for AI Agents](/blog/what-is-runtime-authority-for-ai-agents) — the canonical definition
- [What is Cycles?](/quickstart/what-is-cycles) — 5-minute overview of the reference implementation
# Querying Balances in Cycles: Understanding Budget State
Budget enforcement is only useful if the system can also answer: **how much is left?**
That is what the balances endpoint provides.
`GET /v1/balances` returns the current budget state for one or more scopes — how much is allocated, how much is reserved, how much has been spent, and how much remains.
## What a balance represents
A balance is a snapshot of a single scope's budget state in a specific unit.
Each balance includes:
### remaining (SignedAmount)
The budget available for new reservations.
This is the most important field for operational decisions. It tells you how much room is left.
Remaining can be negative when a scope is in overdraft (debt exceeds available budget).
Formula: `remaining = allocated - spent - reserved - debt`
### reserved (Amount)
The amount currently locked by active reservations.
This budget is held but not yet spent. It will either become spent (via commit) or return to remaining (via release or expiration).
### spent (Amount)
The cumulative amount successfully committed through reservations or events.
This is actual, accounted usage.
### allocated (Amount)
The total budget cap for this scope, if a fixed allocation exists.
When present, allocated represents the maximum budget the scope can consume. When absent, the remaining amount is authoritative and cannot be derived from other fields.
### debt (Amount)
Overdraft consumption that occurred when insufficient budget was available.
Debt is created only when the `ALLOW_WITH_OVERDRAFT` overage policy is used and budget is insufficient at commit time.
When debt is present and no overdraft limit is configured, new reservations are blocked until it is repaid. When an overdraft limit is set, debt within the limit does not block new reservations.
### overdraft_limit (Amount)
The maximum debt this scope is allowed to carry.
If absent or zero, no overdraft is permitted.
### is_over_limit (Boolean)
Whether the scope's debt exceeds its overdraft limit.
When true, all new reservations against this scope are blocked.
## The ledger invariant
When allocated, spent, reserved, and debt are all present, the following invariant holds:
```
remaining = allocated - spent - reserved - debt
```
The server guarantees this relationship. Clients can rely on it for consistency checking.
If allocated is absent, remaining is authoritative — the client must not try to derive it.
## Querying balances
### Request
```
GET /v1/balances?tenant=acme&app=support-bot
```
At least one subject filter must be provided:
- `tenant`
- `workspace`
- `app`
- `workflow`
- `agent`
- `toolset`
Additional parameters:
- `include_children` — if true, include child scopes in the response (default: false; may be ignored by v0 implementations)
- `limit` — maximum results per page (1–200, default: 50)
- `cursor` — opaque cursor from a previous response for pagination
Queries are always scoped to the effective tenant. The server rejects requests that attempt to query another tenant's balances with `403 FORBIDDEN`.
### Response
```json
{
"balances": [
{
"scope": "tenant:acme",
"scope_path": "tenant:acme",
"remaining": { "unit": "USD_MICROCENTS", "amount": 85000000 },
"reserved": { "unit": "USD_MICROCENTS", "amount": 5000000 },
"spent": { "unit": "USD_MICROCENTS", "amount": 10000000 },
"allocated": { "unit": "USD_MICROCENTS", "amount": 100000000 }
},
{
"scope": "app:support-bot",
"scope_path": "tenant:acme/app:support-bot",
"remaining": { "unit": "USD_MICROCENTS", "amount": 22000000 },
"reserved": { "unit": "USD_MICROCENTS", "amount": 3000000 },
"spent": { "unit": "USD_MICROCENTS", "amount": 5000000 },
"allocated": { "unit": "USD_MICROCENTS", "amount": 30000000 }
}
],
"has_more": false
}
```
### Pagination
Responses are paginated:
- `limit` — maximum results per page (1–200, default 50)
- `cursor` — opaque cursor from a previous response
- `has_more` — whether more results exist
- `next_cursor` — cursor for the next page
## Use cases
### Operator dashboards
Balances provide the data needed for budget dashboards:
- which tenants are near their limits
- which workflows are consuming the most
- where reserved amounts are high (indicating active work)
- which scopes have debt
### Automated degradation
Systems can query balances to make proactive decisions:
- if remaining is below a threshold, switch to a smaller model
- if reserved is a large fraction of remaining, reduce concurrency
- if debt is present, pause new work
### Budget monitoring and alerting
Balances enable alerting rules:
- warn when remaining drops below 20% of allocated
- alert when debt exceeds 80% of overdraft_limit
- alert when is_over_limit becomes true
### Capacity planning
Historical balance queries (collected over time) reveal consumption patterns:
- average daily spend by tenant
- peak reserved amounts by workflow
- debt frequency by scope
## Understanding scope and scope_path
Each balance has two identifiers:
- **scope** — the individual scope identifier (e.g., `tenant:acme`, `app:support-bot`)
- **scope_path** — the full hierarchical path (e.g., `tenant:acme/app:support-bot`)
The scope_path places the balance in the hierarchy. The scope identifies the individual level.
## Unit consistency
All amount fields within a single balance share the same unit.
A scope may have balances in multiple units (e.g., both USD_MICROCENTS and TOKENS), but each balance object has a single unit.
## Balances are eventually consistent
Balance queries reflect the current server state, which includes all committed and reserved amounts.
However, under high concurrency, balances may be slightly behind the most recent operations. They are suitable for dashboards, monitoring, and planning — not for real-time budget decisions.
For real-time budget decisions, use reservations (which are atomic and concurrency-safe).
## Reading a balance — a practical example
Consider this balance:
```json
{
"scope": "workflow:refund-assistant",
"scope_path": "tenant:acme/app:support-bot/workflow:refund-assistant",
"remaining": { "unit": "USD_MICROCENTS", "amount": 12000000 },
"reserved": { "unit": "USD_MICROCENTS", "amount": 3000000 },
"spent": { "unit": "USD_MICROCENTS", "amount": 15000000 },
"allocated": { "unit": "USD_MICROCENTS", "amount": 30000000 }
}
```
This tells us:
- The workflow was allocated $0.30 (`30,000,000 / 10^8`)
- It has spent $0.15 so far
- $0.03 is currently reserved by active work
- $0.12 remains available for new reservations
Verify the invariant: `30,000,000 - 15,000,000 - 3,000,000 - 0 = 12,000,000` — correct.
## Negative remaining
When a scope has debt, remaining can be negative:
```json
{
"remaining": { "unit": "USD_MICROCENTS", "amount": -2000000 },
"debt": { "unit": "USD_MICROCENTS", "amount": 5000000 },
"overdraft_limit": { "unit": "USD_MICROCENTS", "amount": 10000000 },
"is_over_limit": false
}
```
This scope is in overdraft but not over-limit. It has $0.05 in debt within a $0.10 overdraft limit. Because the overdraft limit is configured, new reservations are still allowed as long as remaining budget covers the estimate.
## Summary
The balances API gives operators and systems visibility into budget state across all scopes:
- **remaining** — how much room is left
- **reserved** — how much is held by active work
- **spent** — how much has been consumed
- **allocated** — the total budget cap
- **debt** and **overdraft_limit** — overdraft state
- **is_over_limit** — whether the scope is blocked
This data powers dashboards, monitoring, alerting, automated degradation, and capacity planning.
For real-time budget enforcement, use reservations. For understanding budget state, use balances.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
# Reservation Recovery and Listing in Cycles
In production systems, things go wrong.
A client crashes after creating a reservation but before storing the reservation ID. A network partition delays a commit. An operator needs to find stuck reservations that are holding budget.
The Cycles protocol provides two endpoints for these situations:
- `GET /v1/reservations` — list and filter reservations
- `GET /v1/reservations/{reservation_id}` — get details for a specific reservation
Both are optional in v0 deployments, but they are essential for production operations.
Tenant callers use `X-Cycles-API-Key`. Operators may also use `X-Admin-API-Key` on reservation list/detail; with admin auth, `GET /v1/reservations` requires a `tenant` query parameter because the admin key has no effective tenant.
## Recovering a lost reservation ID
The most common recovery scenario: a client created a reservation, received the response, but crashed before persisting the `reservation_id`.
The reservation exists on the server. Budget is held. But the client has no way to commit or release it.
The solution is to query by idempotency key:
```
GET /v1/reservations?idempotency_key=my-unique-key-123
```
Idempotency keys are expected to be unique per (effective tenant, endpoint, idempotency_key), so the server SHOULD return at most one matching reservation. The spec makes this a SHOULD rather than a MUST, so robust recovery code should tolerate the unlikely case of multiple matches.
The client recovers the `reservation_id` and can then commit or release as needed.
This is why idempotency keys should be generated and persisted before creating the reservation — they serve as the recovery handle.
## Listing reservations
The listing endpoint supports several filters:
### By status
```
GET /v1/reservations?status=ACTIVE
```
Reservation statuses are:
- **ACTIVE** — the reservation is live and budget is held
- **COMMITTED** — actual usage has been recorded
- **RELEASED** — the reservation was canceled and budget returned
- **EXPIRED** — the TTL (plus grace period) elapsed without commit or release
Filtering by `status=ACTIVE` is the most operationally useful — it shows all reservations currently holding budget.
### By subject fields
```
GET /v1/reservations?tenant=acme&app=support-bot
GET /v1/reservations?workflow=refund-assistant
```
Subject filters match against the canonical Subject fields: tenant, workspace, app, workflow, agent, and toolset.
Filtering on custom dimensions is out of scope for v0.
### By idempotency key
```
GET /v1/reservations?idempotency_key=run-abc-step-3
```
The server SHOULD return at most one reservation matching the key. This is the primary recovery mechanism.
### Pagination
Responses are paginated:
- `limit` — maximum results per page (1–200, default 50)
- `cursor` — opaque cursor from a previous response
- `has_more` — whether more results exist
- `next_cursor` — cursor for the next page
### Time-window filters
`GET /v1/reservations` supports three independent ISO 8601 time windows:
- `from` / `to` filter `created_at_ms`.
- `expires_from` / `expires_to` filter `expires_at_ms`.
- `finalized_from` / `finalized_to` filter `finalized_at_ms`.
Each bound is inclusive and may be supplied alone. Blank values are treated as unset. If the lower bound is greater than the upper bound for a pair, the server returns `400 INVALID_REQUEST`. `finalized_at_ms` is only populated on COMMITTED and RELEASED rows, so ACTIVE and EXPIRED rows do not match `finalized_*` filters.
Example: find active reservations that have already passed their expiry:
```bash
curl -G "http://localhost:7878/v1/reservations" \
-H "X-Cycles-API-Key: $TENANT_API_KEY" \
--data-urlencode "status=ACTIVE" \
--data-urlencode "expires_to=2026-07-03T12:00:00Z" \
--data-urlencode "sort_by=expires_at_ms" \
--data-urlencode "sort_dir=asc"
```
### Sorting (v0.1.25.12+)
`GET /v1/reservations` accepts two optional query parameters to order results server-side:
- `sort_by` — one of `reservation_id`, `tenant`, `scope_path`, `status`, `reserved`, `created_at_ms`, `expires_at_ms`. When omitted, the server uses its default Redis-SCAN order.
- `sort_dir` — `asc` or `desc`. Defaults to `desc` when `sort_by` is provided.
The `reserved` key sorts by the integer `amount` within each row (well-defined because the v0 single-unit-per-reservation invariant holds). The `scope_path` key sorts lexicographically over the canonical scope path string (e.g. `tenant:acme/workspace:prod/agent:x`). The `tenant` key sorts over `Subject.tenant` only.
Unknown `sort_by` or `sort_dir` values return HTTP 400 `INVALID_REQUEST`. Older servers that don't recognize the params ignore them without error (additive-parameter guarantee).
**Cursor binding.** When `sort_by` is provided, the returned cursor binds to the `(sort_by, sort_dir, filters)` tuple. Reusing a cursor under a different sort key or filter set returns HTTP 400. Reset the cursor whenever you change sort key, direction, or filters.
**Hydration warning.** Current reference servers hydrate all matching rows for sorted reservation listings, then sort and slice. If a sorted query hydrates 2,000 or more rows, the server logs a WARN. Rows beyond 2,000 are not truncated in v0.1.25.39+; narrow filters (`status`, `idempotency_key`, workspace/app/workflow/agent/toolset, or the time-window filters above) still keep incident queries faster and easier to reason about.
```
GET /v1/reservations?status=ACTIVE&sort_by=expires_at_ms&sort_dir=asc&limit=100
```
This returns the 100 oldest-expiring active reservations — useful for incident response when you need to force-release soon-to-expire reservations before they churn.
### Field projection
List rows always include scalar lifecycle fields such as `committed` when present. Larger or optional maps are opt-in through `include`:
- `include=metadata` projects reserve-time metadata.
- `include=committed_metadata` projects commit-time metadata.
- `include=evidence` projects recorded CyclesEvidence refs keyed by `reserve`, `commit`, and `release`.
`include` is projection-only: it does not change which rows match, does not affect ordering, and does not invalidate sorted cursors if it changes between pages.
## Getting reservation details
```
GET /v1/reservations/{reservation_id}
```
Returns the full state of a specific reservation:
- **reservation_id** — the unique identifier
- **status** — current lifecycle state (ACTIVE, COMMITTED, RELEASED, EXPIRED)
- **subject** — the original Subject (tenant, workspace, app, etc.)
- **action** — the original Action (kind, name, tags)
- **reserved** — the amount that was reserved
- **committed** — the amount that was committed (if status is COMMITTED)
- **created_at_ms** — when the reservation was created
- **expires_at_ms** — when the reservation expires (or expired)
- **finalized_at_ms** — when the reservation reached a terminal state; present only on COMMITTED and RELEASED reservations, absent on ACTIVE and EXPIRED ones
- **scope_path** — the canonical scope path
- **affected_scopes** — all scopes impacted by this reservation
- **idempotency_key** — the creation idempotency key (if the server persists it)
- **metadata** — any metadata attached to the reservation
- **committed_metadata** — metadata attached to the commit request, when present
- **evidence** — CyclesEvidence refs keyed by artifact type, when evidence was emitted
This endpoint is useful for debugging specific reservations and understanding their full lifecycle.
## Tenancy enforcement
Both listing and detail endpoints enforce tenant isolation:
- results are scoped to the effective tenant derived from the API key
- if a `tenant` query parameter is provided, it must match the effective tenant
- attempting to get details for a reservation owned by a different tenant returns `403 FORBIDDEN`
A tenant cannot see or access another tenant's reservations.
Under `X-Admin-API-Key`, reservation list requires `tenant` as an explicit filter and reservation detail can read any reservation by ID. This is the admin-on-behalf-of operator path used by the dashboard and incident runbooks.
## Use cases
### Stuck reservation detection
Periodically query `GET /v1/reservations?status=ACTIVE` and check for reservations with `expires_at_ms` in the past that have not yet been finalized.
These may indicate server-side cleanup delays or edge cases worth investigating.
### Budget leak investigation
When budget appears lower than expected, list active reservations to see what is currently held:
```
GET /v1/reservations?status=ACTIVE&tenant=acme&app=support-bot
```
This shows all live reservations holding budget for that app. High counts or large reserved amounts may explain the discrepancy.
### Post-incident analysis
After a budget incident, query reservations by workflow or agent to understand the pattern:
```
GET /v1/reservations?workflow=refund-assistant&status=COMMITTED
```
This shows all committed reservations for that workflow, which helps trace what consumed the budget.
### Client crash recovery
Current official SDK lifecycle helpers replay known-actual settlement from their durable journal automatically. The manual sequence below applies to raw clients and application-owned lifecycles:
1. Check durable local state for in-progress reservation IDs, exact settlement bodies, and idempotency keys
2. For any missing reservation IDs, query by the idempotency key that was generated before the reservation
3. For each recovered reservation, check its status:
- **ACTIVE**: commit or release depending on whether work completed
- **EXPIRED**: if work already completed and actual usage is known, record it through `POST /v1/events` with the stored settlement key; if work did not run, no settlement is due; create a new reservation only before starting new work
- **COMMITTED** or **RELEASED**: no action needed
This recovery pattern depends on persisting idempotency keys before creating reservations and persisting known-actual settlement before its first request. Never compensate with a fresh key while an original request remains ambiguous.
## Error conditions
### GET /v1/reservations (listing)
- `400 INVALID_REQUEST` — malformed query parameters
- `401 UNAUTHORIZED` — invalid API key
- `403 FORBIDDEN` — tenant mismatch
### GET /v1/reservations/{reservation_id} (detail)
- `401 UNAUTHORIZED` — invalid API key
- `403 FORBIDDEN` — reservation owned by a different tenant
- `404 NOT_FOUND` — reservation never existed
- `410 RESERVATION_EXPIRED` — reservation has expired
## Summary
The reservation listing and detail endpoints provide operational visibility and recovery capabilities:
- **Recovery**: query by idempotency key to recover lost reservation IDs
- **Monitoring**: list active reservations to understand what is holding budget
- **Debugging**: get full reservation details including status, amounts, scopes, and timestamps
- **Investigation**: filter by subject fields and status for post-incident analysis
These endpoints are optional in v0, but essential for production operations where client crashes, network issues, and budget investigations are a reality.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
- Review [SDK Settlement Recovery and Durability](/protocol/sdk-settlement-recovery-and-durability)
# Reservation TTL, Grace Period, and Extend in Cycles
Reservations in Cycles do not live forever.
Every reservation has a time-to-live (TTL). When the TTL (plus grace period) elapses without a commit or release, the reservation expires and the held budget returns to the available pool.
This is by design.
Without TTL, a crashed client or lost network connection could lock budget indefinitely. That would create phantom consumption — budget that appears used but is not serving any real work.
TTL prevents that. But it also creates a design question: how should long-running work keep its reservation alive?
That is where extend comes in.
## How TTL works
When a reservation is created, the server sets an expiration time:
```
expires_at_ms = created_at_ms + ttl_ms
```
The default TTL is 60 seconds (`ttl_ms: 60000`). The allowed range is 1 second to 24 hours (`1000` to `86400000` milliseconds).
Tenant-level TTL controls are reference-server behavior, not part of the v0 protocol: through the Admin API, tenant administrators can change the default with `default_reservation_ttl_ms` and restrict the range with `max_reservation_ttl_ms` — any requested TTL exceeding the tenant maximum is capped automatically.
When server time passes `expires_at_ms`, the reservation has expired: it can no longer be extended. In-flight commits and releases are still accepted during the grace period (below). Once the grace period has also elapsed without a commit or release, the server marks the reservation `EXPIRED` and returns the reserved budget to the available pool.
## How grace period works
Real systems have in-flight operations. A commit request may be in transit when the TTL expires.
The grace period provides a short window after TTL expiration during which commits and releases are still accepted:
```
hard_expiry = expires_at_ms + grace_period_ms
```
The default grace period is 5 seconds (`grace_period_ms: 5000`).
The allowed range is 0 to 60 seconds (`0` to `60000` milliseconds).
During the grace period:
- commit and release are still accepted
- extend is not accepted (the reservation must be extended before TTL expires)
After the grace period, the reservation is marked `EXPIRED`. Any attempt to commit or release returns `410 RESERVATION_EXPIRED`. (Expiry is not a finalization — only `COMMITTED` and `RELEASED` are finalized states, which is why an expired reservation returns `410 RESERVATION_EXPIRED` rather than `409 RESERVATION_FINALIZED`.)
### Why the grace period maximum is 60 seconds
The 60-second ceiling is an interoperability constraint. Longer grace periods would extend the window during which a crashed client can keep budget locked, increasing the risk of zombie reservations.
For operations that need more time, use extend as a heartbeat rather than relying on a long grace period.
## How extend works
`POST /v1/reservations/{reservation_id}/extend` extends the TTL of an active reservation.
The `extend_by_ms` parameter is added to the current `expires_at_ms` (not to the request time):
```
new_expires_at_ms = current_expires_at_ms + extend_by_ms
```
The allowed range for `extend_by_ms` is 1 millisecond to 24 hours (`1` to `86400000` milliseconds).
Extend requires an `idempotency_key`. Replaying the same request with the same idempotency key returns the original response.
### What extend does not change
Extend updates only the expiration time. It does not change:
- the reserved amount
- the unit
- the subject
- the action
- the scope path
- the affected scopes
The reservation is the same reservation. It just lives longer.
### Error conditions
- If the reservation is already `COMMITTED` or `RELEASED`: `409 RESERVATION_FINALIZED`
- If the reservation has already expired (past `expires_at_ms`): `410 RESERVATION_EXPIRED`
- If the tenant's `max_reservation_extensions` limit has been reached: `409 MAX_EXTENSIONS_EXCEEDED`
- If the reservation was never created: `404 NOT_FOUND`
Note: extend must happen before TTL expires, not during the grace period. The grace period only covers commit and release.
### Extension limits
Tenant administrators can set `max_reservation_extensions` via the Admin API to limit how many times a single reservation can be extended (default: 10). This prevents infinite zombie reservations from clients that heartbeat indefinitely without committing or releasing.
## The heartbeat pattern
For long-running workflows:
1. Create a reservation with a bounded TTL
2. Schedule heartbeat extensions from the server's returned remaining lifetime
3. When work completes, commit actual usage
4. If the heartbeat stops (crash, timeout), the reservation expires naturally and budget is returned
This pattern keeps budget locked only while the client is actively running. If the client crashes, the reservation expires quickly and budget is freed.
### Server-authoritative scheduling
Since runtime spec revision 0.1.25.16, successful create and extend responses can carry `remaining_ttl_ms`. It is computed from the authoritative server clock and is the normative scheduling input whenever present.
Recompute the schedule from every exact, schema-valid HTTP `200` create or extend response:
```text
rtt = monotonic receive time - monotonic send time
lead_floor = max(0, remaining_ttl_ms - rtt)
attempt_budget = max(enforced request timeout, 1000ms, 2 × max observed rtt)
safety_margin = max(1000ms, 2 × max observed rtt)
retry_reserve = 2 × attempt_budget + safety_margin
next_delay = max(0, lead_floor - retry_reserve)
```
The reserve leaves room for one failed attempt, a same-key recovery attempt, and scheduling/network margin. For example, a returned 60-second lead, 10-second request timeout, and 1.5-second maximum observed RTT produce a 23-second retry reserve and a cadence of about 37 seconds.
::: warning Keep extend timeouts well below the lease
The default 60-second TTL cannot support a safe positive delay if one extend attempt may consume 30 seconds: the retry reserve is at least 61 seconds. Configure the enforced per-extend timeout well below half the smallest expected lease. Current official SDK defaults use finite 7–12 second attempt budgets.
:::
After a timeout, connection failure, 5xx, 429, or malformed/other 2xx response, the client keeps the same idempotency key and recomputes the retry window from the last valid server response:
```text
current_lead = max(0, last lead_floor - monotonic elapsed time)
retry_window = current_lead - attempt_budget - safety_margin
```
For a timeout, connection error, 5xx, or ambiguous 2xx, the recovery delay is:
```text
retry_delay = min(30000ms, current_lead / 4, retry_window)
```
The client may keep retrying while the freshly recomputed window is positive. A zero window permits one immediate same-key recovery attempt; if that also fails before an intervening success, the client stops. A negative window proves that no complete attempt plus margin fits. The client also stops if neither monotonic elapsed time nor the window decreases between consecutive failures, which prevents a coarse-clock zero-time loop.
A 429 delay is honored only when a valid non-negative delta-seconds `Retry-After` fits inside the window; the heartbeat does not accept the HTTP-date form or invent an earlier retry. Any other 4xx stops and surfaces without rotating the key.
If the initial create is ambiguous, no valid lead exists yet. The client makes at most one immediate retry with the same create key, then stops and surfaces if that retry is also ambiguous.
If a valid response produces zero delay, one immediate extension with a fresh key is allowed. A second consecutive zero-delay success stops the heartbeat instead of burning the server's extension limit in a tight loop. Missing or unreliable monotonic timing also produces zero delay and therefore reaches this guard.
### Same-key replay and `remaining_ttl_ms`
A successful create or extend replay returns the original response except for `remaining_ttl_ms`, which the server recomputes when constructing the replay. The value is `0` when the reservation is no longer active and may conservatively understate lead if a later, separately keyed extension moved expiry farther out.
This field is volatile transport metadata. It is deliberately excluded from the CyclesEvidence payload, so recomputing it does not change the original evidence identity.
### Older servers without the field
When `remaining_ttl_ms` is absent, a client cannot reliably infer the true lease from `expires_at_ms`: server policy may cap either each extension grant or the maximum lead beyond server time. The official SDKs retain a bounded measured-grant heuristic as a non-normative, best-effort fallback. The exact schema-valid HTTP `200` success rule still applies; only scheduling changes.
The fallback intentionally prefers over-beating and possible extension-budget exhaustion over an unobservable lease lapse. This differs from the field-bearing path, which has enough information to prove when no safe retry fits and stop. Do not use the fallback merely because local monotonic timing is unavailable; a field-bearing response still requires the primary algorithm.
## The clients handle this automatically
Python `@cycles`, TypeScript `withCycles`, Spring `@Cycles`, and Rust `ReservationGuard` schedule extensions in the background and stop the heartbeat when the lifecycle closes.
Heartbeat failures use a warning policy: each failure reports the reservation ID and retry/stop disposition, but it does not cancel the guarded work or suppress final settlement. Recoverable failures retry with the same key. Permanent conditions — `RESERVATION_EXPIRED`, `RESERVATION_FINALIZED`, `MAX_EXTENSIONS_EXCEEDED`, `TENANT_CLOSED`, and `NOT_FOUND` — stop the heartbeat.
If known spend reaches commit after the reservation expires, current SDK lifecycle helpers recover it through a durable `POST /v1/events` fallback. See [SDK Settlement Recovery and Durability](/protocol/sdk-settlement-recovery-and-durability).
## Choosing TTL values
### Short TTL (10–30 seconds)
Best for:
- model calls and tool invocations that complete quickly
- actions with predictable duration
- systems where fast budget recovery matters
### Medium TTL (30–120 seconds)
Best for:
- multi-step workflows
- actions with moderate latency
- systems using heartbeat extension
### Long TTL (2–60 minutes)
Best for:
- batch processing
- background jobs with known duration
- systems where extend is not practical
Use long TTLs sparingly. They increase the risk of zombie reservations.
## Choosing grace period values
### Default (5 seconds)
Sufficient for most synchronous operations where commits arrive shortly after execution.
### Higher (10–30 seconds)
Useful for:
- streaming model calls with high latency
- slow external APIs
- actions where commit may be delayed by processing
### Zero
Use zero grace period when:
- strict TTL enforcement is required
- the client always commits well before expiration
- zombie prevention is a priority
## Common mistakes
### Mistake 1: Using large TTLs instead of heartbeats
A 10-minute TTL works but locks budget for the full duration if the client crashes. Prefer short TTLs with extend.
### Mistake 2: Relying on grace period for normal operation
The grace period is a safety net, not a design tool. If commits routinely arrive during the grace period, the TTL is too short.
### Mistake 3: Forgetting to handle RESERVATION_EXPIRED
If a commit arrives after the grace period, it is rejected. Do not create a new reservation after the work already ran; that would treat completed work as new authorization. Recover known spend as an idempotent direct event. Current official SDK lifecycle helpers do this automatically and persist the recovery across restart.
### Mistake 4: Extending after TTL expires
Extend must happen before `expires_at_ms`. If the client waits too long between heartbeats, the reservation may expire before the next extend arrives.
## Summary
Reservations in Cycles are time-bounded by design:
- **TTL** controls how long budget is held
- **Grace period** provides a short safety window after TTL for in-flight commits
- **Extend** refreshes TTL as a heartbeat for long-running operations
The recommended pattern for most systems:
- Keep TTL bounded and choose an enforced request timeout that leaves room for the retry reserve
- Schedule from `remaining_ttl_ms` whenever the server supplies it
- Reuse the same idempotency key for ambiguous extend recovery
- Set grace period to 5–10 seconds
- Recover known spend through an idempotent event if commit reaches an expired reservation
This keeps budget locked only while work is actively running, and recovers quickly when clients crash.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
# SDK Recovery Conformance Matrix
Wire-format compatibility is not enough for an SDK. The failure choreography
must still preserve known spend when a response is lost, a reservation expires,
credentials rotate, or the process restarts.
This matrix publishes the current evidence snapshot for every official core
SDK against the shared [recovery profile
0.3](https://github.com/runcycles/cycles-protocol/blob/main/client-recovery/PROFILE.md).
Each cell links to the machine-readable report containing the exact native
tests executed for that scenario. The four implementations are Python,
TypeScript, Spring / Java, and Rust.
## What a pass proves
The shared runner invokes the SDK adapter once per scenario in a fresh process.
The adapter must execute named native behavior tests and return their exact
identifiers. A pass therefore proves that the SDK's claimed native tests ran
successfully at the pinned implementation commit.
The runner deliberately does not disclose the expected request choreography to
the adapter. Code review still verifies that each named native test asserts the
catalog's expected calls and outcomes. This is native-test-backed conformance,
not a claim that a second black-box client independently reproduced the SDK
internals.
## Status definitions
- **Pass** — the pinned report passed the scenario and names at least one native
behavior test.
- **Fail** — the runner completed but the scenario or its native tests failed.
- **Not claimed** — an implementation does not claim that profile level.
- **Stale** — the displayed SDK or profile commit has moved beyond the report.
The pinned result remains historical evidence, but current conformance must
be re-established.
The SDK README badge follows the current `main` CI workflow. The snapshot above
is intentionally pinned to exact commits so later source changes cannot rewrite
what was tested.
## Guarantee boundary
Durable recovery begins only after the lifecycle helper knows the actual amount
and persists settlement. If a process dies before the provider returns actual
usage, the SDK cannot invent the missing amount. Applications requiring that
stronger guarantee must durably checkpoint the provider receipt or usage before
acknowledging the downstream operation.
See [SDK Settlement Recovery and
Durability](/protocol/sdk-settlement-recovery-and-durability) for the journal,
replay, expiry fallback, configuration, and operational contract.
# SDK Settlement Recovery and Durability
A reservation protects budget before work starts. Settlement records what the work actually consumed after it runs. Once actual usage is known, losing that settlement would undercount spend even though the provider or tool already performed the work.
The official Python 0.5.2+, TypeScript 0.4.2+, Java/Spring 0.3.2+, and Rust 0.3.2+ SDK lifecycle helpers implement the Cycles [durable recovery profile](https://github.com/runcycles/cycles-protocol/blob/main/client-recovery/PROFILE.md). They persist unresolved known-actual settlement before the first request and replay it with the original idempotency key until the outcome is proven.
## Where the guarantee starts
The durability guarantee starts only after the lifecycle helper knows the actual amount and begins commit or direct-event settlement.
If the process dies before the provider returns usage, the SDK does not know what amount to settle. Applications that need convergence across that boundary must durably checkpoint the provider receipt or actual usage before acknowledging the downstream operation.
::: warning Lifecycle helpers and low-level clients differ
The automatic guarantee belongs to helpers that own the reserve → execute → settle flow: Python `@cycles` and streaming contexts, TypeScript `withCycles` and stream handles, Spring `@Cycles`, and Rust `ReservationGuard`.
Low-level `commit` and `createEvent` methods expose the idempotent protocol primitives but do not persist application-owned requests automatically. Direct callers must supply equivalent durable retry storage.
:::
## Recovery choreography
Once actual usage is known, a conforming lifecycle helper follows this sequence:
1. Build the commit request and its same-key `/v1/events` fallback.
2. Atomically persist the unresolved settlement before the first request leaves the process.
3. Send the commit.
4. Remove the record only after a proven settlement success or a genuine, understood terminal rejection.
5. Retain and replay the record after an ambiguous outcome, authentication failure, unclassifiable 4xx response, retry exhaustion, or restart.
Only an exact, schema-valid HTTP `200` commit response proves commit success. Only an exact, schema-valid HTTP `201` event response proves fallback success. A malformed body or another 2xx status remains ambiguous and is retried with the original idempotency key.
The SDK never releases a reservation merely because settlement is ambiguous or credentials failed after the action ran. Releasing at that point would return budget for spend that may already have occurred.
## Expired commits become direct events
If commit returns HTTP `410` or `RESERVATION_EXPIRED`, the reservation hold has already been reclaimed. Retrying that reservation cannot record the spend.
The lifecycle helper therefore switches the durable record to event mode before calling `POST /v1/events`. The event preserves the original subject, action, actual amount, metrics, metadata, and idempotency key, and adds recovery metadata such as `recovered_reservation_id`.
Commit and direct-event idempotency are scoped to their respective endpoints. Reusing the stored key makes repeated recovery safe, including concurrent replay by multiple processes.
## Journal behavior
Durable recovery is enabled by default and uses `~/.runcycles/commit-journal` unless configured otherwise.
- Records are written atomically and use `v2-.json` filenames.
- Malformed or unsupported-version records are quarantined without preventing other valid records from replaying.
- A persisted HTTP 429 `Retry-After` becomes an absolute not-before time, so restart does not retry early.
- Records are partitioned by server and principal. Configure the tenant identity so the partition remains stable across API-key rotation.
- API keys are not stored in journal records.
- Journal files contain settlement data, including subject, action, actual usage, and metadata. Protect the directory as sensitive application state and avoid putting secrets in metadata.
If journal I/O fails, the SDK reports the durability failure and may still attempt synchronous settlement. It cannot promise restart recovery for a record that was never written.
## Configuration and shutdown
| SDK | Journal settings | Bounded drain |
|---|---|---|
| Python | `journal_enabled`, `journal_dir`, `retry_flush_timeout` | Automatic process-exit flush; unresolved records remain for next startup |
| TypeScript | `journalEnabled`, `journalDir`, `retryFlushTimeout` | `flushPendingCommits(timeoutMs?)` |
| Java/Spring | `cycles.journal.enabled`, `cycles.journal.dir`, `cycles.retry.flush-timeout` | Spring context shutdown |
| Rust | `journal_enabled`, `journal_dir` | `flush_pending_commits_with_timeout(...)`; also available on the blocking client |
Disabling inline/background retry does not disable persistence. To opt out of recovery durability, the journal must also be disabled. Do that only when the application supplies an equivalent durable settlement mechanism.
## Availability behavior
Official lifecycle helpers fail closed before execution. If reservation
creation cannot produce a schema-valid `ALLOW` or `ALLOW_WITH_CAPS` response
after its bounded same-key recovery, the helper surfaces an error and does not
invoke the guarded function. An explicit denial, authentication failure, or
malformed response is never converted into permission to run.
This is a phase-specific policy rather than one blanket outage switch:
- **Before authorization:** no proven allow means no guarded action.
- **After authorization:** heartbeat failure is observable but does not cancel
work under the baseline warning policy.
- **After actual usage is known:** settlement is durably retained and replayed
instead of being discarded during an outage.
Low-level clients return the protocol primitives and leave application control
flow to the caller. The official lifecycle helpers do not expose a generic
`fail_open` option. Applications that deliberately execute without a
reservation are outside the pre-execution authority guarantee and must make
that unmetered path explicit in their own policy and telemetry.
## Heartbeat failures do not erase settlement
Heartbeat extension protects the lease while work runs; it is not settlement. Under the official SDKs' warning policy:
- transport and terminal extend failures are logged with the reservation ID and whether the heartbeat will retry or stop;
- recoverable failures reuse the same idempotency key and follow the protocol's safe retry window;
- the guarded action continues; and
- final settlement is still attempted when actual usage becomes known.
A stopped heartbeat can cause commit to arrive after expiry. The durable event fallback above is what preserves that known spend.
## Operational checklist
- Keep journaling enabled on every process that uses high-level lifecycle helpers.
- Configure `tenant` when possible so pending records survive API-key rotation.
- Put the journal on persistent local storage, not an ephemeral container layer.
- Invoke the bounded drain during graceful shutdown where the SDK exposes one.
- Alert on journal I/O failures, quarantined records, retained authentication failures, retry exhaustion, and heartbeat stop messages.
- Do not delete pending records manually unless the settlement outcome has been independently proven.
## Next steps
- [SDK Recovery Conformance Matrix](/protocol/sdk-recovery-conformance)
- [How Reserve → Commit Works](/protocol/how-reserve-commit-works-in-cycles)
- [Reservation TTL, Grace Period, and Extend](/protocol/reservation-ttl-grace-period-and-extend-in-cycles)
- [Error Handling Patterns](/how-to/error-handling-patterns-in-cycles-client-code)
- [SDK Recovery Conformance Profile](https://github.com/runcycles/cycles-protocol/blob/main/client-recovery/PROFILE.md)
# Standard Metrics and Metadata in Cycles
Budget enforcement tells you whether work is allowed and how much it costs.
But production systems need more than cost numbers. They need to know what happened during execution — how many tokens were consumed, how long it took, which model version was used, and any custom data relevant for debugging or analytics.
That is what standard metrics and metadata provide.
## Where metrics and metadata appear
Metrics and metadata can be attached to two operations:
- **Commits** (`POST /v1/reservations/{id}/commit`) — when finalizing a reservation
- **Events** (`POST /v1/events`) — when recording direct debit usage
Both accept an optional `metrics` field and an optional `metadata` field.
Neither field is echoed back in the response. Commit responses carry `status`, `charged`, `released`, `balances`, and `cycles_evidence`; event responses carry `status`, `event_id`, `charged`, and `balances`. To read metadata back after the fact, retrieve the reservation (see below).
## Standard metrics
The protocol defines a `StandardMetrics` schema with four named fields and an extensible custom map:
### tokens_input
```json
"tokens_input": 1250
```
The number of input tokens consumed by the operation. Integer, minimum 0.
Useful for tracking prompt size and correlating with model pricing.
### tokens_output
```json
"tokens_output": 430
```
The number of output tokens generated. Integer, minimum 0.
Useful for tracking generation length and correlating with output pricing (which is typically higher than input pricing).
### latency_ms
```json
"latency_ms": 1840
```
The total operation latency in milliseconds. Integer, minimum 0.
Useful for SLA monitoring, performance analysis, and identifying slow operations that may need different TTL or timeout handling.
### model_version
```json
"model_version": "gpt-4o-mini-2024-07-18"
```
The actual model or tool version used. String, maximum 128 characters.
This is important because the model requested and the model used are not always the same. Providers may route to different versions, and this field captures what actually ran.
### custom
```json
"custom": {
"cache_hit": "true",
"region": "us-east-1",
"retry_count": "2"
}
```
An open map for arbitrary additional metrics. Values can be any JSON type (strings, numbers, booleans, objects).
Use custom metrics for anything not covered by the standard fields — cache behavior, retry counts, routing decisions, feature flags, or domain-specific measurements.
## A complete metrics example
```json
{
"idempotency_key": "commit-run-42-step-7",
"actual": { "unit": "USD_MICROCENTS", "amount": 285000 },
"metrics": {
"tokens_input": 1250,
"tokens_output": 430,
"latency_ms": 1840,
"model_version": "gpt-4o-mini-2024-07-18",
"custom": {
"cache_hit": "false",
"prompt_template": "summarize-v3"
}
}
}
```
## Metadata
Metadata is a separate field from metrics. It is an open map for arbitrary key-value pairs:
```json
{
"idempotency_key": "commit-run-42-step-7",
"actual": { "unit": "USD_MICROCENTS", "amount": 285000 },
"metadata": {
"user_id": "user-456",
"session_id": "session-001",
"feature_flag_bucket": "variant-b",
"external_trace_id": "otel-abc-xyz-789"
}
}
```
Metadata is intended for application-level audit, debugging, and correlation to systems outside Cycles — not for operational metrics, and not for the server-managed `trace_id` and `request_id` (those flow through first-class response headers and response-body fields; see the next section).
::: tip Don't put the server trace_id in metadata
As of v0.1.25 the server manages its own 32-hex W3C Trace Context `trace_id` and per-request `request_id`. Both flow on response headers (`X-Cycles-Trace-Id`) and in response bodies — you don't need to (and shouldn't) shove them into `metadata`. Use `metadata` for application-level values like your own OpenTelemetry / Datadog trace id, user id, or session id. See [Correlation and Tracing](/protocol/correlation-and-tracing-in-cycles).
:::
### Metrics vs metadata vs server correlation identifiers
- **Metrics** are about what happened during execution (tokens, latency, model version).
- **Metadata** is about application-level context (user IDs, session IDs, feature flags, external trace ids).
- **Server correlation identifiers** — `request_id`, `trace_id`, `correlation_id` — are managed natively by the Cycles server. You do not need to pack them into `metadata`.
All three are optional. All three are stored with the commit or event record. But they serve different analytical purposes. See [Correlation and Tracing](/protocol/correlation-and-tracing-in-cycles) for the server's three-tier identifier model.
## Where metadata also appears
Metadata is accepted on several other operations beyond commits and events:
- **Reservation creation** (`POST /v1/reservations`) — attach context to the reservation itself
- **Reservation extend** (`POST /v1/reservations/{id}/extend`) — attach debugging metadata to extend operations
- **Decide** (`POST /v1/decide`) — attach context to preflight budget checks
This means a full reservation lifecycle can carry metadata from creation through commit:
1. Create reservation with `metadata: { "external_trace_id": "..." }`
2. Extend with `metadata: { "heartbeat_seq": "3" }`
3. Commit with `metadata: { "app_request_id": "..." }` and `metrics: { ... }`
Commit metadata is preserved on the reservation and returned by `GET /v1/reservations/{id}` as `committed_metadata` — distinct from the reserve-time `metadata` field, which is returned on the same response — so the metadata attached at reserve and commit time is auditable after the fact, not just sent and forgotten.
## Metrics in client code
Attach metrics and metadata through the context object inside a decorated function or annotated method. The SDK automatically includes these in the commit request when the function returns.
::: code-group
```python [Python]
from runcycles import cycles, get_cycles_context, CyclesMetrics
@cycles(estimate=1000)
def chat(prompt: str) -> str:
response = call_llm(prompt)
ctx = get_cycles_context()
ctx.metrics = CyclesMetrics(
tokens_input=response.usage.prompt_tokens,
tokens_output=response.usage.completion_tokens,
latency_ms=elapsed,
model_version=response.model,
)
ctx.commit_metadata = {
"app_request_id": app_request_id,
"external_trace_id": otel_trace_id,
}
return response.text
```
```java [Java (Spring Boot)]
@Cycles("1000")
public ChatResponse chat(String prompt) {
ChatResponse response = chatModel.call(prompt);
CyclesReservationContext ctx = CyclesContextHolder.get();
CyclesMetrics metrics = new CyclesMetrics();
metrics.setTokensInput(response.getUsage().getPromptTokens());
metrics.setTokensOutput(response.getUsage().getCompletionTokens());
metrics.setLatencyMs(elapsed);
metrics.setModelVersion(response.getMetadata().getModel());
ctx.setMetrics(metrics);
ctx.setCommitMetadata(Map.of(
"app_request_id", appRequestId,
"external_trace_id", otelTraceId
));
return response;
}
```
:::
## Why standard metrics matter
### Cost attribution
Tokens input and output, combined with model version, enable precise cost attribution:
- which model was used
- how many tokens it consumed
- what the actual cost was
This connects budget accounting to provider-level billing.
### Performance monitoring
Latency metrics across commits reveal:
- which actions are slow
- whether latency correlates with budget consumption
- where timeout or TTL adjustments are needed
### Audit trail
Metadata creates a traceable path from budget operations back to the originating request, user, or workflow run.
When investigating a budget incident, metadata helps answer: who triggered this, from which session, as part of which trace?
### Analytics
Over time, standard metrics enable aggregate analysis:
- average tokens per model call by action type
- latency distributions by model version
- cache hit rates across workflows
- cost efficiency trends
Note that the protocol defines how metrics are *submitted*, not how they are retrieved or aggregated. v0 defines no endpoint that returns stored metrics, so retrieval and aggregation are implementation-defined — via server-side export, a log pipeline, or direct store access.
## Best practices
### Always include tokens and model version on LLM calls
These are the minimum metrics that make budget data actionable. Without them, cost numbers exist without context.
### Use metadata for correlation IDs
Attach your own correlation keys — e.g. `app_request_id`, `external_trace_id`, or `session_id` — to every commit. This makes it possible to join budget data with application logs and distributed traces. (Use distinct names rather than `request_id`/`trace_id`, which are server-managed and should not be duplicated into metadata — see the trace-context note above.)
### Keep custom metrics stable
Treat custom metric keys like a schema. Changing keys breaks downstream analytics. Add new keys freely, but avoid renaming or removing existing ones without coordination.
### Do not put sensitive data in metrics or metadata
Metrics and metadata are stored and may be visible through admin interfaces or log aggregation. Do not include PII, secrets, or authentication tokens.
## Summary
Standard metrics and metadata enrich budget operations with execution context:
- **tokens_input** and **tokens_output** — token consumption
- **latency_ms** — operation duration
- **model_version** — actual model used
- **custom** — extensible metrics map
- **metadata** — application correlation keys, audit context, and debugging data
These fields are optional but recommended. They turn budget accounting from raw cost numbers into actionable operational data.
## Server-side operational metrics
The metrics above describe the **execution-context fields** a client attaches to each commit or event. They are stored with the protocol record and surface in admin audit trails — they are not echoed on commit or event responses.
Cycles also exposes **Prometheus metrics** on each service's `/actuator/prometheus` endpoint for operational monitoring. These are aggregate counters and histograms — they do not replace per-request metrics, they complement them.
The runtime server (`cycles-server` v0.1.25.10+) currently publishes 11 domain counters and one maintenance timer. The core lifecycle counters are:
- `cycles_reservations_reserve_total{tenant, decision, reason, overage_policy}`
- `cycles_reservations_commit_total{tenant, decision, reason, overage_policy}`
- `cycles_reservations_release_total{tenant, actor_type, decision, reason}`
- `cycles_reservations_extend_total{tenant, decision, reason}`
- `cycles_reservations_expired_total{tenant}`
- `cycles_reservations_quarantined_total{tenant, reason}`
- `cycles_reservations_created_at_index_reads_total{outcome}`
- `cycles_events_total{tenant, decision, reason, overage_policy}`
- `cycles_overdraft_incurred_total{tenant}`
Maintenance adds `cycles_maintenance_runs_total{job, outcome}` and `cycles_maintenance_duration_seconds{job, outcome}`; evidence enqueue failures use `cycles_evidence_emit_failed_total{artifact_type}`.
The admin server currently exposes seven custom counters, including `cycles_admin_audit_writes_total{path_class, outcome}`—**alert on `outcome=error` nonzero** to catch silent audit-coverage loss—and tenant-close reconciliation/outbox signals.
The events service (`cycles-server-events` v0.1.25.6+) currently publishes 17 webhook-delivery, evidence-worker, dispatcher, and security counters plus one delivery-latency timer. See the [Prometheus Metrics Reference](/how-to/prometheus-metrics-reference) for the authoritative inventory and labels.
The runtime and events services gate their optional `tenant` label with `cycles.metrics.tenant-tag.enabled`. The runtime defaults it to `true`; the events service defaults it to `false`. Admin `cycles_admin_*` counters do not carry a tenant label, so this toggle does not apply there.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
# Tenant-Close Cascade Semantics
Closing a tenant is more than a status flip. Every object the tenant owns — budgets, reservations, API keys, webhook subscriptions — has to move to a terminal state too, and every subsequent mutation against those objects has to be rejected cleanly. The `cycles-governance-admin-v0.1.25.yaml` spec's `CASCADE SEMANTICS` section is the normative contract for how this works.
This page is the operator-facing reference. For the admin API surface that honors the contract, see the [Admin API Guide](/admin-api/guide). For the error-code side, see [Error Codes and Error Handling](/protocol/error-codes-and-error-handling-in-cycles#tenant-closed-409).
## Why this exists
Before the cascade contract was formalized (spec document revision 0.1.25.31), closing a tenant was a pure status flip. Operators would then have to separately:
- drain open reservations (or let TTL expire)
- freeze or close each owned budget
- revoke every API key
- disable every webhook subscription
In practice nobody did all of that. The `/admin/overview` dashboard would accumulate "FROZEN budgets on CLOSED tenants" rows forever — inflating the "needs attention" counter with rows operators had no user-reachable path to resolve.
The cascade contract, formalized at spec document revision 0.1.25.31 and shipping in `cycles-server-admin` v0.1.25.35+, makes the close operation do the right thing atomically (or eventually-atomically) instead.
## Version gate matrix
| Feature | Minimum component | What works |
|---|---|---|
| Rule 1 cascade (budgets + reservations) | `cycles-server-admin` v0.1.25.35 | Closing a tenant cascades budgets → CLOSED and open reservations → RELEASED |
| Rule 2 guard (budget operations, webhook create/update) | `cycles-server-admin` v0.1.25.35 | Admin-plane mutations against closed-tenant budgets, and webhook create/update, return `409 TENANT_CLOSED` |
| Rule 2 full coverage (policies, api-keys, remaining webhook mutations) | `cycles-server-admin` v0.1.25.36 | All remaining admin-plane mutation endpoints also return `409 TENANT_CLOSED` |
| Rule 2 runtime guard (reservation create/commit/release/extend) | `cycles-server` v0.1.25.47 (runtime spec v0.1.25.13) | Persisting reservation mutations on a closed tenant return `409 TENANT_CLOSED`; fresh dry-run/decide evaluations return `200 decision=DENY reason_code=TENANT_CLOSED` |
| Dashboard tombstone + cascade preview UI | `cycles-dashboard` v0.1.25.43 | Banner, CLOSE dialog preview, humanized errors, cascade audit/event chip |
**Pre-v0.1.25.35 admin servers do not cascade** — operators must manually freeze budgets, revoke keys, and disable webhooks before or after closing the tenant.
## The two rules
### Rule 1 — Close Cascade (server-issued)
On any `* → CLOSED` tenant transition (via `PATCH /v1/admin/tenants/{id}` or `POST /v1/admin/tenants/bulk-action` with `action=CLOSE`), the server drives each owned object into its nearest terminal state:
| Owned object | Terminal state | Notes |
|---|---|---|
| `BudgetLedger` | `CLOSED` | Stamps `closed_at`; drains any outstanding `reserved` back to `remaining`; preserves the final balance snapshot for audit. |
| `ApiKey` | `REVOKED` | Stamps `revoked_at`. |
| Open `Reservation` | `RELEASED` (reason `tenant_closed`) | No overage debt recorded. |
| `WebhookSubscription` | `DISABLED` | Re-enable is blocked by Rule 2 below, making `DISABLED` effectively-terminal for closed owners without adding a new enum value. |
**Ordering.** Ordering is a Mode A concern. Within Mode A's single transaction, the spec says the order SHOULD be:
1. Drain open reservations
2. Close budgets
3. Disable webhooks and revoke API keys (any order)
4. Flip `tenant.status` to `CLOSED` last
Mode B (see below) inverts this by design — the tenant flip commits **first**, and children converge afterward under the Rule 2 guard. Since runcycles' reference server implements Mode B, do not rely on this ordering in practice.
**Audit and event emission.** One record per mutated owned object. The emitted **Event rows** share a server-composed `correlation_id` of the form `tenant_close_cascade::` — query `GET /v1/admin/events?correlation_id=...` to reconstruct the cascade. **Audit rows** carry `request_id`/`trace_id` (the AuditLogEntry schema has no correlation field); join them via the originating request's `request_id`. The dotted `*_via_tenant_cascade` names are emitted as Event `event_type`s (declared in the governance spec's `EventType` enum since document revision v0.1.25.35, so cascade Events validate against `Event.event_type` and are filterable like any other lifecycle event; Event emission is SHOULD-level, while the audit entries below are a MUST); the matching audit rows are written as `operation="tenant_close_cascade"` with `resource_type`/`resource_id` identifying the mutated object. Reserved dotted names:
- `budget.closed_via_tenant_cascade`
- `webhook.disabled_via_tenant_cascade`
- `api_key.revoked_via_tenant_cascade`
- `reservation.released_via_tenant_cascade`
Servers should additionally emit one Event (dispatchable to webhook subscribers) per mutated owned object using the same dotted kind as its `event_type`. `reservation.released_via_tenant_cascade` is a **ledger-level aggregate**: reservation objects live on the runtime plane, so the admin plane emits one event per closed budget whose `reserved > 0` at close time — identified by `ledger_id`, not an individual reservation — carrying the drained amount as `released_amount`.
See [Webhook Event Delivery Protocol](/protocol/webhook-event-delivery-protocol) for how these land on webhook deliveries.
**Idempotency.** Re-issuing close on an already-CLOSED tenant is a no-op *at the tenant level* (returns the current state). Under Mode A no child work remains; under Mode B a re-close completes any outstanding child transitions — without emitting duplicate audit or event rows for already-terminal children. Operator-issued re-close is in fact one of the spec-sanctioned convergence mechanisms for an interrupted Mode B cascade.
### Mode A vs Mode B
The spec (v0.1.25.31) permits two cascade modes:
- **Mode A — Atomic Cascade (preferred).** All owned-object terminal transitions and the tenant flip commit in a single transaction. Rollback on any failure. Strongest guarantee but requires a transactional store.
- **Mode B — Flip-First with Guarded Cascade (conformant alternative).** Tenant flip to `CLOSED` commits first, making Rule 2 active; server then drives children to terminal states inline or via a reconciler. Valid only when: (a) Rule 2 activates at/before flip durability, (b) cascade is idempotent, (c) eventual convergence is guaranteed within a documented bound, (d) observable reads of non-terminal children of a CLOSED tenant remain consistent with stored status until cascade reaches them.
Both modes deliver the same client-observable contract: once the tenant is `CLOSED`, admin-plane mutations against its owned objects return `409 TENANT_CLOSED` regardless of whether the per-object state has flipped yet.
**runcycles' reference server uses Mode B** — backed by Redis, not a transactional database. Operators should not rely on atomic visibility of all child transitions; instead rely on Rule 2.
### Rule 2 — Terminal-Owner Mutation Guard
Every mutating admin-plane operation on an owned object whose parent tenant is `CLOSED` MUST reject with:
```http
HTTP 409 Conflict
Content-Type: application/json
{
"error": "TENANT_CLOSED",
"message": "Tenant is closed; is read-only.",
"request_id": "req-...",
"trace_id": "..."
}
```
GET endpoints remain available — closed-tenant state is still readable post-mortem for audit and compliance.
### Operations that guard
The spec's Rule 2 scopes the guard to **every mutating admin-plane operation** whose target resource has an owning tenant. Its enumeration (explicitly non-exhaustive) covers:
**Budget plane:**
- `POST /v1/admin/budgets/freeze`
- `POST /v1/admin/budgets/unfreeze`
- `POST /v1/admin/budgets/fund`
- `PATCH /v1/admin/budgets?scope=&unit=` (updateBudget)
- `POST /v1/admin/budgets/bulk-action` (per-row)
**Policy plane (tenant-scoped policies):**
- `POST /v1/admin/policies` (createPolicy)
- `PATCH /v1/admin/policies/{policy_id}` (updatePolicy)
**API key plane:**
- `POST /v1/admin/api-keys` (createApiKey)
- `PATCH /v1/admin/api-keys/{key_id}` (updateApiKey)
- `DELETE /v1/admin/api-keys/{key_id}` (revokeApiKey)
**Webhook plane (admin and tenant self-service paths):**
- `POST /v1/admin/webhooks`, `PATCH`, `DELETE`, `POST .../test`
- `POST /v1/webhooks`, `PATCH`, `DELETE`, `POST .../test`
- `POST /v1/admin/webhooks/{id}/replay`
- `POST /v1/admin/webhooks/bulk-action` (per-row)
**Bulk-action per-row semantics.** On bulk-action endpoints, rows targeting a closed tenant go into the `failed[]` bucket with `error_code=TENANT_CLOSED` — they don't abort the rest of the batch.
### What the runtime plane sees
**Spec (normative):** Rule 2's scope explicitly includes runtime reservation mutations — "any reservation create/commit/release/extend" — so a conformant server MUST reject them with `409 TENANT_CLOSED` once the CLOSED flip is durable. Runtime spec revision v0.1.25.13 binds this directly on the runtime plane: `TENANT_CLOSED` is now part of the runtime `ErrorCode` enum, with a normative closed-tenant binding in the runtime spec's ERROR SEMANTICS. `cycles-server` 0.1.25.47 implements it — the reference-implementation gap this section previously documented is closed. Shipped behavior:
- **Persisting mutations → `409 TENANT_CLOSED`.** Reservation create (`dry_run` absent or `false`), commit, release, and extend against a `CLOSED` owning tenant return `409` with `error=TENANT_CLOSED` once the flip is durable. The check runs inside the same Lua scripts as the budget mutations, so a post-flip request can never partially succeed, and it is not subject to any config-cache TTL.
- **Precedence.** For non-replay mutations, `TENANT_CLOSED` takes precedence over the reservation-state errors (`RESERVATION_FINALIZED`, `RESERVATION_EXPIRED`) — Rule 2 rejects regardless of the child's own current status. Same-key replays of mutations that succeeded before the close are the exception: they retain replay precedence and return the original stored response.
- **Non-persisting evaluations never 409.** A fresh (non-replay) `dry_run=true` create or `POST /v1/decide` on a closed tenant returns `200` with `decision=DENY` and `reason_code=TENANT_CLOSED` — dry-run and decide outcomes are attestations of what live execution would do (and may be captured as signed CyclesEvidence), so they reflect the closed tenant as-if-live instead of erroring. Same-key replays of pre-close evaluations return their original payload.
- **Fail-closed on malformed tenant records.** A tenant record that is present but whose status cannot be determined (undecodable JSON, non-object, missing or non-string `status`, unknown status string) returns `500 INTERNAL_ERROR` before any mutation — on the non-persisting surface too, because the server cannot attest against corrupt governance state. A subject tenant with **no** tenant record at all (runtime-only deployments without a governance plane) is not guarded.
- **Evidence receipts.** A mutation-surface `409 TENANT_CLOSED` on the evidence endpoints — persisting create, commit, release — emits an `error` CyclesEvidence envelope and stamps `cycles_evidence` on the response, like the other live denial codes (extend is not an evidence endpoint).
- **Reads unaffected.** `GET /v1/reservations` and `GET /v1/reservations/{id}` keep working on a closed tenant's reservations for post-close audit, mirroring Rule 2's read-access rule.
**Observability note:** the close cascade revokes the tenant's API keys, and the runtime auth filter rejects CLOSED-tenant keys per request — so tenant-key calls usually still fail with `401 UNAUTHORIZED` before the guard is consulted. In practice `409 TENANT_CLOSED` surfaces in two places: **admin-on-behalf-of release** — of the four guarded mutations, release is the only one the runtime plane exposes to `X-Admin-API-Key` (the admin dual-auth allowlist covers reservation list/get/release only, so create/commit/extend are never admin-key reachable) — and the **post-flip/pre-revocation race window** for tenant-key requests on any of the four.
**Version scope:** `cycles-server` 0.1.25.46 and earlier surface closed tenants on the runtime plane only as `401`s (revoked/rejected keys) or budget-state errors (`BUDGET_CLOSED` on cascaded budgets). Client code on those versions should treat "tenant was closed" as a `401`/`BUDGET_CLOSED` scenario; on 0.1.25.47+ handle `409 TENANT_CLOSED` as well.
## Operator recipe — closing a tenant
```bash
# 1. Preview what will cascade — confirms intent before the irreversible close.
# The Tenant object itself carries no child counts, so use the list endpoints:
curl -s http://localhost:7979/v1/admin/tenants/acme-corp \
-H "X-Admin-API-Key: $ADMIN_KEY" | jq '{tenant_id, status}'
# Budgets that will be closed (reserved > 0 will emit released_via_tenant_cascade)
curl -s "http://localhost:7979/v1/admin/budgets?tenant_id=acme-corp" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
| jq '.ledgers[] | {scope, unit, status, reserved}'
# Open reservations that will be released (admin key requires the tenant filter)
curl -s "http://localhost:7979/v1/reservations?tenant=acme-corp&status=ACTIVE" \
-H "X-Admin-API-Key: $ADMIN_KEY" | jq '.reservations[]'
# 2. Close the tenant — cascade runs automatically
curl -X PATCH http://localhost:7979/v1/admin/tenants/acme-corp \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "CLOSED"}'
# 3. Verify the cascade audit entries
curl -s "http://localhost:7979/v1/admin/audit/logs?tenant_id=acme-corp&operation=tenant_close_cascade" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
| jq '.logs[] | {operation, resource_type, resource_id}'
```
If the `/admin/overview` dashboard still shows frozen budgets on the closed tenant after a few seconds, your admin server is on a pre-v0.1.25.35 version — the cascade hasn't shipped and you need to upgrade. See the [Admin API Guide — Tenant close and cascade semantics](/admin-api/guide).
## Dashboard behavior
The [Cycles Admin Dashboard](/quickstart/deploying-the-cycles-dashboard) (v0.1.25.43+) surfaces cascade behavior:
- **Closed-tenant banner.** Amber read-only banner on `TenantDetailView` when `tenant.status === 'CLOSED'`: "Tenant closed — all owned objects are read-only."
- **CLOSE confirm-dialog preview.** The dialog enumerates what will be terminated: owned budgets, webhook subscriptions, API keys, open reservations, with counts from already-loaded state. "This cannot be undone."
- **`TENANT_CLOSED` humanizer.** Any mutation that races the cascade (stale tab, deep-link, in-flight request) surfaces "Tenant is closed — this object is read-only." instead of the raw 409.
- **Cascade event chip.** Events and audit rows with `_via_tenant_cascade` event-kind suffixes render a small amber "tenant cascade" chip, visually distinguishing cascade-triggered state changes from user-driven ones when operators correlate by `correlation_id`.
See [Using the Cycles Dashboard](/how-to/using-the-cycles-dashboard#closed-tenant-tombstone-and-cascade-preview) for the full UI walkthrough.
## Backward compatibility
- Pre-v0.1.25.35 admin servers do NOT cascade. Operators on older versions must continue manually terminating owned objects before or after the tenant close.
- Pre-v0.1.25.35 servers do NOT return `409 TENANT_CLOSED` — they return the previous per-endpoint error (`409 BUDGET_FROZEN`, `403 FORBIDDEN`, etc.) or may accept mutations against orphaned objects.
- Pre-v0.1.25.36 servers have partial Rule 2 coverage — `.35` guarded budget operations and webhook create/update; `.36` completed policies, api-keys, the remaining webhook mutations, and per-row bulk-action.
- Pre-v0.1.25.43 dashboards render TENANT_CLOSED as a raw 409 error without the humanizer and without the cascade-preview dialog.
- `cycles-server` (runtime) 0.1.25.46 and earlier do NOT return `409 TENANT_CLOSED` on reservation mutations — closed tenants surface there only as `401`s (revoked/rejected keys) or `BUDGET_CLOSED`. The runtime guard ships in 0.1.25.47 (runtime spec v0.1.25.13).
**Re-issuing close on an already-CLOSED tenant** is idempotent at the tenant level across all versions — it returns the current state and emits no duplicate audit entries for already-terminal children. Under Mode B it is not a pure no-op: a re-close completes any outstanding child transitions left by an interrupted cascade.
## Related
- [Error Codes and Error Handling — TENANT_CLOSED](/protocol/error-codes-and-error-handling-in-cycles#tenant-closed-409)
- [Admin API Guide — Tenant close and cascade semantics](/admin-api/guide)
- [Tenant Creation and Management — CLOSED status](/how-to/tenant-creation-and-management-in-cycles#closed)
- [Using the Cycles Dashboard](/how-to/using-the-cycles-dashboard#closed-tenant-tombstone-and-cascade-preview)
- [Webhook Event Delivery Protocol](/protocol/webhook-event-delivery-protocol) — cascade event kinds
# Understanding Units in Cycles: USD_MICROCENTS, TOKENS, CREDITS, and RISK_POINTS
Every amount in Cycles — reservations, commits, events, balances — has a unit.
The unit tells the system what is being measured and how to interpret the number. The `unit` field is required on every amount — there is no default unit.
Cycles defines four standard units:
- **USD_MICROCENTS**
- **TOKENS**
- **CREDITS**
- **RISK_POINTS**
Choosing the right unit affects how budgets are expressed, how estimates are calculated, and how the ledger is interpreted.
## USD_MICROCENTS
USD_MICROCENTS is the most precise monetary unit in the protocol.
### Definition
```
1 USD_MICROCENTS = 10⁻⁶ cents = 10⁻⁸ dollars
1 USD = 100 cents = 100,000,000 USD_MICROCENTS
```
### Why microcents?
Model calls are cheap individually. A single GPT-4o-mini call might cost a fraction of a cent.
If the unit were dollars or even cents, many per-call amounts would round to zero. That makes accounting meaningless.
USD_MICROCENTS uses integer arithmetic with enough precision to represent per-call costs without floating point issues.
### Range
The amount field is a 64-bit integer (int64 format) with a minimum of 0. Negative amounts are not valid in standard Amount fields. The SignedAmount variant (used for Balance.remaining) allows negative values to represent overdraft state.
Maximum: `9.22 × 10¹⁸ USD_MICROCENTS ≈ $92.2 billion`
That is more than sufficient for any realistic budget.
### When to use USD_MICROCENTS
- you want to track cost in monetary terms
- you need precision for per-call accounting
- your budgets are expressed in dollars, euros, or other currency (converted to USD_MICROCENTS)
- you want direct correlation between budget state and provider bills
### Example
A model call that costs $0.003:
```
$0.003 = 0.3 cents = 300,000 USD_MICROCENTS
```
Reserve 300,000. If actual usage is 280,000, commit 280,000 and the remaining 20,000 is released automatically.
## TOKENS
TOKENS represents integer token counts, as used by most LLM providers.
### When to use TOKENS
- your budgets are expressed in token counts
- you want direct mapping to provider token metering
- cost varies by model and you want to track consumption in a model-independent unit
- you are budgeting computational capacity rather than monetary cost
### Example
A model call expects to use up to 2,000 input tokens and 500 output tokens.
Reserve 2,500 TOKENS. After the call, actual input was 1,800 and output was 450. Commit 2,250 TOKENS.
### Considerations
Token-based budgeting is simpler but does not account for price differences between models. A budget of 100,000 TOKENS means different monetary costs depending on whether those tokens go to GPT-4o or GPT-4o-mini.
If you need monetary awareness, use USD_MICROCENTS and convert token counts to cost at reservation time.
## CREDITS
CREDITS is a generic integer unit for custom budget systems.
### When to use CREDITS
- your platform defines its own internal currency
- you want to abstract away underlying costs
- different tenants have different pricing and you want to normalize
- you want to decouple budget governance from provider pricing
### Example
A platform defines:
- 1 credit = 1 model call (regardless of model size)
- or 1 credit = some normalized cost unit
Tenants are allocated credits per billing period. Each model call reserves and commits in credits.
### Considerations
Credits require a mapping layer to translate between credits and actual cost. This adds complexity but provides flexibility in pricing and plan design.
CREDITS is a generic integer unit and is optional in v0 implementations — confirm your server supports it before budgeting in credits.
## RISK_POINTS
RISK_POINTS is a generic integer unit for risk-based budgeting.
### When to use RISK_POINTS
- you want to budget side-effect risk rather than cost
- some actions are expensive in risk but cheap in money
- you want to limit how many high-risk actions a tenant or workflow can take
- safety governance is more important than cost governance
### Example
A platform defines:
- read-only model call = 1 risk point
- tool invocation with external API = 5 risk points
- write operation (email, ticket, payment) = 20 risk points
A workflow is allowed 100 risk points per run. This bounds the total side-effect surface regardless of monetary cost.
### Considerations
Risk points are subjective. The team must define what each point represents and calibrate the scale. But for systems where side-effect control matters more than cost control, risk points can be more operationally useful than monetary units.
Like CREDITS, RISK_POINTS is a generic integer unit and is optional in v0 implementations — confirm your server supports it before budgeting in risk points.
## Unit consistency
All amounts within a single reservation lifecycle must use the same unit.
- The reservation estimate, commit actual, and balance amounts must all be in the same unit
- The server returns `400 UNIT_MISMATCH` in three distinct cases:
- **Reserve and decide** — the `estimate.unit` doesn't match any budget stored for the derived scopes, but at least one of those scopes has a budget in a different unit
- **Commit** — the `actual.unit` differs from the reservation's `estimate.unit`
- **Event** — the `actual.unit` doesn't match the budget stored for the target scope
- When the cause is a wrong unit, servers SHOULD populate the error's `details` object with `scope` (where the mismatch was detected), `requested_unit` (what the client sent), and `expected_units` (the units for which a budget does exist), so clients can self-correct without a separate lookup. When no budget exists at any scope in *any* unit, the server returns `404 NOT_FOUND` with the message `"Budget not found for provided scope: ..."` — the runtime plane uses a single `NOT_FOUND` wire code for all resource-not-found conditions, and the message field carries the specific reason. On `/v1/decide` and dry-run reserve, the same "no budget" condition instead surfaces as `200 DENY` with `reason_code=BUDGET_NOT_FOUND`.
This prevents accidental unit confusion (e.g., reserving in tokens and committing in dollars).
Within a balance, all amount fields (remaining, reserved, spent, allocated, debt, overdraft_limit) share the same unit.
## Choosing a unit
A simple decision framework:
### Use USD_MICROCENTS when:
- monetary cost is the primary concern
- you want direct provider bill correlation
- you need per-call precision
- your budgets are expressed in currency
### Use TOKENS when:
- token consumption is the primary metric
- you budget by computational capacity
- you want model-independent counting
- monetary cost varies and you want to decouple
### Use CREDITS when:
- you have a custom platform currency
- you want to abstract provider pricing
- different tenants have different cost structures
- you want plan-based allocation (e.g., 10,000 credits/month)
### Use RISK_POINTS when:
- side-effect control matters more than cost
- you want to bound high-risk actions
- safety governance is a primary goal
- cost and risk are not well correlated
## Multiple units in one system
A single Cycles deployment can use different units for different scopes or action types.
For example:
- tenant budgets in USD_MICROCENTS (monetary ceiling)
- workflow budgets in TOKENS (capacity planning)
- agent budgets in RISK_POINTS (side-effect control)
However, a single reservation lifecycle uses exactly one unit. Multi-unit atomic operations are a v1+ concern.
## Summary
Units define what Cycles is measuring:
- **USD_MICROCENTS** — monetary cost with per-call precision
- **TOKENS** — LLM token consumption
- **CREDITS** — custom platform currency (optional in v0 implementations)
- **RISK_POINTS** — side-effect risk accounting (optional in v0 implementations)
Choosing the right unit depends on whether the primary concern is cost, capacity, pricing abstraction, or safety.
All amounts within a reservation lifecycle and within a balance must use the same unit.
## Next steps
To explore the Cycles stack:
- Read the [Cycles Protocol](https://github.com/runcycles/cycles-protocol)
- Run the [Cycles Server](https://github.com/runcycles/cycles-server)
- Manage budgets with [Cycles Admin](https://github.com/runcycles/cycles-server-admin)
- Integrate with Python using the [Python Client](/quickstart/getting-started-with-the-python-client)
- Integrate with TypeScript using the [TypeScript Client](/quickstart/getting-started-with-the-typescript-client)
- Integrate with Spring Boot or Spring AI using the [Spring Boot starter](https://github.com/runcycles/cycles-spring-boot-starter) or the [Spring AI starter](https://github.com/runcycles/cycles-spring-ai-starter)
# Webhook Event Delivery Protocol
Cycles emits events when budget state changes and delivers them to webhook subscriptions via HTTP POST. This page is the authoritative reference for the delivery protocol.
## Delivery headers
Every webhook delivery includes these HTTP headers:
| Header | Value | Description |
|--------|-------|-------------|
| `Content-Type` | `application/json` | Always JSON |
| `X-Cycles-Signature` | `sha256=` | HMAC-SHA256 of the raw body using the subscription signing secret. Present whenever the subscription has a signing secret (if no secret was provided when the subscription was created, the server-generated secret is used); omitted when the subscription has no signing secret. |
| `X-Cycles-Event-Id` | `evt_abc123...` | Unique event ID. Use for deduplication. |
| `X-Cycles-Event-Type` | `budget.exhausted` | Dot-notation event type for routing. |
| `X-Cycles-Trace-Id` | `0af7651916cd43dd8448eb211c80319c` | 32-hex W3C Trace Context identifier for the logical operation this event belongs to. Always present on deliveries from v0.1.25.7+ events services. |
| `traceparent` | `00--<16-hex-span>-` | W3C Trace Context v00. `trace_id` matches `X-Cycles-Trace-Id`. `span-id` is freshly generated per delivery (not reused from the inbound request). `trace-flags` preserves the inbound sampling decision when the originating request had a valid `traceparent`, otherwise defaults to `01` (sampled). Always present on v0.1.25.7+ events services. |
| `X-Request-Id` | `req-abc-123` | Present when the originating event carries `request_id`. Narrows to side effects of one HTTP request (vs. `X-Cycles-Trace-Id` which may span many). v0.1.25.7+ events services. |
| `User-Agent` | `cycles-server-events/0.1.25.x` | Service identifier and version. Exact patch suffix tracks the shipped events-service build. |
| Custom headers | Per subscription | From the subscription's `headers` map. |
See [Correlation and Tracing](/protocol/correlation-and-tracing-in-cycles) for the full contract on `trace_id` precedence and propagation.
## Payload format
The body is a JSON-serialized Event object:
```json
{
"event_id": "evt_a1b2c3d4e5f6",
"event_type": "budget.exhausted",
"category": "budget",
"timestamp": "2026-04-01T12:00:00Z",
"tenant_id": "acme-corp",
"scope": "tenant:acme-corp/workspace:prod",
"source": "cycles-admin",
"actor": {
"type": "api_key",
"key_id": "key_abc123",
"source_ip": "10.0.1.50"
},
"data": {
"ledger_id": "led_xyz",
"scope": "tenant:acme-corp/workspace:prod",
"unit": "TOKENS",
"allocated": 10000,
"remaining": 0,
"spent": 10000
},
"correlation_id": "3f2a9c14e0b7d5a1",
"request_id": "req_789",
"trace_id": "0af7651916cd43dd8448eb211c80319c",
"metadata": {}
}
```
Fields `scope`, `actor`, `data`, `correlation_id`, `request_id`, `trace_id`, and `metadata` are optional (omitted when null).
**Correlation fields.** `request_id` narrows to one HTTP request; `trace_id` (32-hex W3C) joins related requests when the caller propagates the same context. `correlation_id` is server-managed: the runtime YAML requires deterministic event-cluster hashes, but the current reference runtime leaves the field absent on its implemented emits; selected admin operations populate explicit IDs such as `webhook_create:` and `webhook_bulk_action::`. See [Correlation and Tracing](/protocol/correlation-and-tracing-in-cycles).
## Event types (51)
The current v0.1.25 Admin API `EventType` enum registers 51 event types across seven categories: budget (17), reservation (6), tenant (6), api_key (7), policy (3), webhook (7), and system (5). Implementations may add future event types, and consumers should ignore unrecognized values gracefully. The per-category tables below list the 47 non-cascade types; the four `*_via_tenant_cascade` types (one each in the budget, reservation, api_key, and webhook categories, added to the enum in governance revision v0.1.25.35) are covered in [Tenant-close cascade fan-out](#tenant-close-cascade-fan-out).
Registration does not guarantee emission. The trigger tables describe each registered type's contract; consult the [Event Payloads Reference](/protocol/event-payloads-reference) for the current reference-service emission matrix before subscribing.
::: info Count note
The 51-type / 7-category count tracks the admin OpenAPI enum. The runtime spec's webhook-event guidance section in `cycles-protocol-v0.yaml` lists 35 event types across 6 categories — it predates the `webhook` lifecycle category and some later enum additions.
:::
### Budget events (16)
| Event Type | Trigger |
|------------|---------|
| `budget.created` | Budget ledger created |
| `budget.updated` | Budget ledger configuration changed |
| `budget.funded` | CREDIT funding operation |
| `budget.debited` | Budget debited (funds removed) |
| `budget.reset` | Budget resized (`allocated` changed; `spent`/`reserved`/`debt` preserved) |
| `budget.reset_spent` | New billing period started (`allocated` set; `spent` cleared or explicitly set; `reserved`/`debt` preserved) |
| `budget.debt_repaid` | Outstanding debt repaid |
| `budget.frozen` | Budget set to FROZEN status (no new reservations) |
| `budget.unfrozen` | Budget restored to ACTIVE from FROZEN |
| `budget.closed` | Budget permanently closed (operator action) |
| `budget.threshold_crossed` | Utilization crossed a configured threshold (e.g., 80%, 95%) |
| `budget.exhausted` | Remaining budget reached zero |
| `budget.over_limit_entered` | Debt exceeded overdraft limit |
| `budget.over_limit_exited` | Debt dropped below overdraft limit |
| `budget.debt_incurred` | New debt created by an ALLOW_WITH_OVERDRAFT commit or direct debit |
| `budget.burn_rate_anomaly` | Spend rate exceeds baseline multiplier within the configured window |
### Reservation events (5)
| Event Type | Trigger |
|------------|---------|
| `reservation.denied` | A dry-run reservation or `/v1/decide` evaluation returned `DENY`; current live 4xx reservation errors do not emit this event |
| `reservation.denial_rate_spike` | Denial rate exceeded threshold within window |
| `reservation.expired` | Reservation TTL expired without commit or release |
| `reservation.expiry_rate_spike` | Expiry rate exceeded threshold within window |
| `reservation.commit_overage` | Commit actual exceeded reserved estimate |
### Tenant events (6)
| Event Type | Trigger |
|------------|---------|
| `tenant.created` | New tenant provisioned |
| `tenant.updated` | Tenant configuration changed |
| `tenant.suspended` | Tenant set to SUSPENDED (blocks new reservations) |
| `tenant.reactivated` | Tenant restored to ACTIVE from SUSPENDED |
| `tenant.closed` | Tenant permanently closed |
| `tenant.settings_changed` | Tenant settings (TTL, overage policy, etc.) modified |
### API key events (6)
| Event Type | Trigger |
|------------|---------|
| `api_key.created` | New API key generated |
| `api_key.revoked` | API key permanently revoked (operator action) |
| `api_key.expired` | API key reached its expiration date |
| `api_key.permissions_changed` | API key permissions modified |
| `api_key.auth_failed` | Authentication attempt with invalid key |
| `api_key.auth_failure_rate_spike` | Auth failure rate exceeded threshold within window |
### Policy events (3)
| Event Type | Trigger |
|------------|---------|
| `policy.created` | New policy rule created |
| `policy.updated` | Policy configuration changed |
| `policy.deleted` | Policy removed |
### Webhook events (6)
| Event Type | Trigger |
|------------|---------|
| `webhook.created` | Webhook subscription created |
| `webhook.updated` | Webhook subscription configuration changed |
| `webhook.paused` | Webhook subscription paused by an operator |
| `webhook.resumed` | Webhook subscription resumed by an operator |
| `webhook.disabled` | Webhook subscription auto-disabled after delivery failures |
| `webhook.deleted` | Webhook subscription deleted |
### System events (5)
| Event Type | Trigger |
|------------|---------|
| `system.store_connection_lost` | Redis connection failed |
| `system.store_connection_restored` | Redis connection recovered |
| `system.high_latency` | Operation latency exceeded threshold |
| `system.webhook_delivery_failed` | Webhook delivery permanently failed |
| `system.webhook_test` | Test webhook sent via POST /v1/admin/webhooks/{id}/test |
### Tenant-accessible events
A webhook subscription **owned by a concrete tenant** can only carry — and can only receive — **tenant-accessible** event classes: `budget.*`, `reservation.*`, and `tenant.*` (29 of the 51 registered event types, including the `budget.*` and `reservation.*` cascade fan-out events the admin server emits on tenant close — see the next section). The **admin-only** classes — `api_key.*`, `policy.*`, `webhook.*`, and `system.*` — belong on subscriptions owned by the operator (the `__system__` owner), never on a tenant-owned row.
This is **governance WEBHOOK SUBSCRIPTION INVARIANT 2** (normative, cross-plane, spec revisions v0.1.25.38–.41): a subscription whose owning `tenant_id` is present and `!= "__system__"` MUST NOT carry an admin-only event type or category — *by any provisioning mechanism*. The invariant is a property of the **owning tenant**, not of the caller or the endpoint, so it holds under tenant self-service auth, admin-key, and admin-on-behalf-of alike. The rationale is confidentiality: the owning tenant controls that subscription's delivery URL and signing secret, and `event_categories` is additive with `event_types` in delivery matching, so an admin-only selector on a tenant-owned row would leak admin governance/security telemetry (`api_key` / `policy` / `webhook` / `system` events) to a tenant-controlled endpoint.
::: warning Enforced at three layers (issue #209)
The guarantee is defense-in-depth across the two services — verify your fleet is fully upgraded:
- **Write** — both provisioning planes reject an admin-only type or category on a concrete-tenant subscription with `400 INVALID_REQUEST`. The tenant self-service plane (`POST /v1/webhooks`, `PATCH /v1/webhooks/{subscription_id}`) since **cycles-server-admin 0.1.25.50** (governance v0.1.25.38); the admin plane (`POST /v1/admin/webhooks?tenant_id=X`, `PATCH /v1/admin/webhooks/{id}`) since **0.1.25.51** (governance v0.1.25.40) — update validates the *effective resulting* selectors (each array as it stands after the update — the request's value where provided, the stored value where omitted; `PATCH` replaces a supplied array, it does not merge), so a status-only reactivation validates the still-stored selectors and can't re-enable a disabled offender that holds admin-only ones. `__system__`-owned subscriptions are exempt (system-wide monitoring is legitimate).
- **Dispatch** — since **0.1.25.51**, live dispatch and replay skip any admin-only event per-event for a concrete-tenant subscription, fail-closed and independent of stored-selector correctness (a default-on startup reconciler additionally strips legacy admin-only selectors from stored non-`DISABLED` rows — best-effort hygiene, see below).
- **Last-mile delivery** — since **cycles-server-events 0.1.25.23**, the delivery worker re-checks the boundary immediately before every outbound POST (initial, retry, and recovered redeliveries), catching deliveries queued before the upgrade and every retry. **Rolling-deploy caveat:** this is a per-worker guarantee — airtight only once *all* delivery workers are on 0.1.25.23.
**Operators upgrading past 0.1.25.49 (admin) / 0.1.25.22 (events)** should audit existing tenant subscriptions for admin-only selectors. Confidentiality does not depend on the audit — the fail-closed dispatch and last-mile boundaries already withhold the events. As storage hygiene, 0.1.25.51 also runs a **default-on, best-effort startup reconciler** (`webhook.category-boundary.reconcile-on-startup`, default `true`) that strips admin-only selectors from stored non-`DISABLED` concrete-tenant rows and disables empty-both rows; it is not the security mechanism (dispatch already withholds the events, and a `DISABLED` row delivers nothing). The [0.1.25.50 release notes](https://github.com/runcycles/cycles-server-admin/releases/tag/v0.1.25.50) carry a `redis-cli`/`jq` recipe to find offenders manually.
:::
#### Monitoring a specific tenant's admin-only events
Because a tenant-owned subscription can no longer carry admin-only classes, per-tenant admin monitoring (e.g. one tenant's `api_key.*` or `policy.*` events pointed at an operator endpoint) moves to a **`__system__`-owned** subscription — create it via `POST /v1/admin/webhooks` with **no `tenant_id`** parameter. A `__system__`-owned subscription may carry admin-only selectors, so name the admin event types you want in `event_types` (create requires a non-empty `event_types` per INVARIANT 1); to cover whole admin categories, pair a representative admin type with `event_categories` (e.g. `event_types: ["api_key.created"]`, `event_categories: ["api_key", "policy"]`). Because `__system__` is in the dispatch union for every tenant, that subscription receives those admin events for **all** tenants; select the tenant you care about **client-side** on the delivered envelope's `tenant_id`.
`scope_filter` generally can't do the per-tenant narrowing here: `api_key.*`, `webhook.*`, and `system.*` events are **null-scoped**, and a `scope_filter` excludes null-scoped events, so it would deliver none of them. The one exception is `policy.*`, which carries a real tenant-bounded `scope` and so *can* be `scope_filter`ed if you only need policy events. For everything else, client-side `tenant_id` filtering is the general solution. The `__system__` row is operator-owned, so its URL and secret stay operator-controlled.
::: tip `/test` probe exception
The owner-triggered webhook test (`POST /v1/webhooks/{id}/test` and its admin twin) POSTs a single synthetic `system.webhook_test` connectivity event **directly** to the subscription's own endpoint, bypassing the dispatch queue. A tenant-owned subscription MAY receive its own test probe even though `system.webhook_test` is a `system.*` (admin-only) type — the payload is an owner-requested `{subscription_id, test:true}` ping carrying no governance telemetry, and the subscription's stored selectors are unchanged and still must satisfy INVARIANT 2. This exception is limited to the synthetic test event on the `/test` operations; no real `system.*`/`api_key.*`/`policy.*`/`webhook.*` event from the event stream reaches a tenant-owned subscription.
:::
### Tenant-close cascade fan-out
The admin server emits cascade fan-out events with the `_via_tenant_cascade` suffix as side effects of a `* → CLOSED` tenant transition (Rule 1 — Close Cascade). All four names are declared in the governance spec's `EventType` enum since document revision v0.1.25.35, so they count toward the 51 registered types and are filterable like any other lifecycle event. Emission is SHOULD-level in the spec (the matching per-object audit entries are a MUST), so non-reference servers may not emit them — keep ignoring unrecognized event types gracefully:
- `budget.closed_via_tenant_cascade` — one per owned `BudgetLedger`.
- `reservation.released_via_tenant_cascade` — a **ledger-level aggregate**: one per closed budget with `reserved > 0`, carrying `released_amount`. Reason `tenant_closed`; no overage debt.
- `api_key.revoked_via_tenant_cascade` — one per owned API key.
- `webhook.disabled_via_tenant_cascade` — one per owned webhook subscription.
All four carry a server-composed `correlation_id` of the form `tenant_close_cascade::`, letting subscribers correlate cascade side effects to the operator action that triggered them (audit rows for the operation join via `request_id`/`trace_id`). The dashboard (v0.1.25.43+) renders a "tenant cascade" chip on audit and event-timeline rows with these suffixes.
See [Tenant-Close Cascade Semantics](/protocol/tenant-close-cascade-semantics) for the full Rule 1 / Rule 2 contract and Mode A / Mode B semantics.
## Delivery status lifecycle
| Status | Meaning |
|--------|---------|
| `PENDING` | Queued for delivery, not yet attempted |
| `SUCCESS` | Delivered and received HTTP 2xx response |
| `RETRYING` | Failed but retries remain, scheduled for retry |
| `FAILED` | All retries exhausted or delivery expired |
## Retry policy
Failed deliveries are retried with exponential backoff:
```
delay = min(initial_delay_ms * backoff_multiplier ^ (attempt - 1), max_delay_ms)
```
| Setting | Default | Description |
|---------|---------|-------------|
| `max_retries` | 5 | Maximum retry attempts (6 total including first attempt) |
| `initial_delay_ms` | 1000 | Delay before first retry |
| `backoff_multiplier` | 2.0 | Multiplier applied per retry |
| `max_delay_ms` | 60000 | Maximum delay cap |
**Default retry schedule:** 1s, 2s, 4s, 8s, 16s (capped at 60s).
**Success criteria:** HTTP response status 200–299.
### Auto-disable
After `disable_after_failures` (default 10) consecutive delivery failures, the subscription status is set to `DISABLED`. The counter resets to 0 on any successful delivery. Disabled subscriptions must be manually re-enabled via `PATCH /v1/admin/webhooks/{id}`.
### Stale delivery handling
Deliveries older than `MAX_DELIVERY_AGE_MS` (default 24 hours) are automatically marked FAILED without attempting HTTP delivery. This prevents delivering stale events after a prolonged events service outage.
## Transport
Outbound webhook deliveries negotiate **HTTP/1.1 only** (no HTTP/2 / h2c). This was pinned in `cycles-server-events` v0.1.25.5 to close a silent body-drop bug against HTTP/2 reverse proxies that upgrade `http://` to h2c (closes `cycles-server-events#16`). Receivers behind HTTP/1.1-only proxies were unaffected; receivers behind HTTP/2-capable proxies gain consistent body delivery.
Response bodies are discarded (`HttpResponse.BodyHandlers.discarding()`) so large responses from misbehaving receivers don't pin memory.
## Signature verification
The `X-Cycles-Signature` header contains `sha256=` where `` is the HMAC-SHA256 of the raw JSON request body using the subscription's signing secret as the key.
**Verification steps:**
1. Read the raw request body as bytes (do not parse JSON first)
2. Compute HMAC-SHA256 using your copy of the signing secret
3. Compare `sha256=` with the `X-Cycles-Signature` header using a constant-time comparison
4. Reject the request if they do not match
See [Webhook Integrations](/how-to/webhook-integrations#signature-verification) for implementation in Python, Node.js, Go, and Java.
## At-least-once delivery
Webhooks are delivered at least once. Duplicates can occur due to:
- Network retries (timeout before response received, but server processed it)
- Events service restart during delivery
- Event replay operations
**Deduplication:** Use the `X-Cycles-Event-Id` header as a deduplication key. Store processed event IDs with a short TTL (24h recommended) and skip events you have already seen.
## Redis keys
The events service uses these Redis data structures (shared with the admin server):
| Key | Type | Written By | Read By | Description |
|-----|------|-----------|---------|-------------|
| `dispatch:pending` | LIST | Admin (LPUSH) | Events (BLMOVE) | Delivery IDs awaiting processing |
| `dispatch:processing` | LIST | Events (BLMOVE) | Events (LREM / recovery) | Claimed delivery IDs retained until acknowledged |
| `dispatch:processing:claimed_at` | ZSET | Events | Events | Claim timestamps used for idle-gated crash recovery |
| `dispatch:processing:claim_owner` | HASH | Events | Events | Per-delivery claim-generation token that prevents a stale worker from acknowledging a successor's claim |
| `dispatch:ordering:lock` | STRING | Events | Events | Renewable owner token for the cross-replica claim/send critical section |
| `dispatch:retry` | ZSET | Events (ZADD) | Events (ZRANGEBYSCORE) | Retry queue (score = timestamp) |
| `dispatch:failed` | LIST | Events (LPUSH/LTRIM) | Operators | Bounded quarantine for corrupt delivery records |
| `delivery:{id}` | STRING | Admin (SET) | Events (GET/SET) | Delivery record JSON (14-day TTL) |
| `event:{id}` | STRING | Admin (SET) | Events (GET) | Event record JSON (90-day TTL) |
| `webhook:{id}` | STRING | Admin (SET) | Events (GET/SET) | Subscription JSON |
| `webhook:secret:{id}` | STRING | Admin (SET, encrypted) | Events (GET, decrypts) | AES-256-GCM encrypted signing secret |
## Next steps
- [Webhook Integrations](/how-to/webhook-integrations) — PagerDuty, Slack, ServiceNow examples with signature verification code
- [Managing Webhooks](/how-to/managing-webhooks) — create, update, test, and replay webhooks
- [Deploying the Events Service](/quickstart/deploying-the-events-service) — setup and configuration
- [Cycles Security](/security#webhook-security) — SSRF protection, encryption, and at-least-once delivery
# Webhook Scope Filter Syntax
Webhook subscriptions can filter events by scope path using the `scope_filter` field. When set, only events whose `scope` matches the filter are delivered to your endpoint.
::: warning Historical implementation divergence — cycles-server-admin 0.1.25.48 and earlier
**Resolved in current releases:** cycles-server-admin **0.1.25.49** (2026-07-10) ships the spec-conformant admin matcher (and applies the same filter on the replay path), and cycles-server **0.1.25.47** refined the runtime matcher's two edge cases to the same semantics (blank/whitespace-only event scopes are treated as unscoped and excluded from filtered subscriptions; trailing-`/*` filters require a non-empty child segment). The two matchers are pinned to the same table of (filter, scope, expected) test cases, so on admin 0.1.25.49+ / runtime 0.1.25.47+ both planes match identically per the spec and cannot drift. The rest of this callout is history for deployments on older versions.
**Scope of the divergence (admin 0.1.25.48 and earlier):** it applies only to **admin-plane-emitted events** (tenant, api_key, policy, webhook lifecycle, and admin-initiated budget events) plus **replay**. Runtime-emitted events (reservations, runtime budget events — the bulk of webhook volume) have always been matched by the runtime server's own matcher, which already implemented the spec semantics below (exact match, trailing-`*` prefix, null scope excluded; the two edge cases refined in 0.1.25.47 — see [Edge cases](#edge-cases)). In other words: on admin 0.1.25.48 and earlier, the *same filter* matched differently depending on which plane emitted the event.
The admin OpenAPI spec (normative, and described first below) defines exact-match semantics with an optional trailing `*` wildcard. The **admin server's** matcher (`WebhookRepository.matchesScope`, 0.1.25.48 and earlier) instead did **literal prefix matching**: a blank filter matches everything, a null event scope always matches, and otherwise the event scope must `startsWith(scope_filter)` — with a bare `"*"` filter special-cased to match everything. Three practical consequences for admin-plane events on those versions:
1. **Trailing-`/*` filters match no admin-plane events.** The admin matcher compares the `*` literally, and real scopes never contain a `*` — so a spec-form filter delivers runtime events but silently misses admin-plane events until 0.1.25.49.
2. **A filter without `*` is a prefix, not an exact match.** `tenant:acme-corp/workspace:prod` also matches `tenant:acme-corp/workspace:prod/workflow:support` (and even `tenant:acme-corp/workspace:prod-eu`, since matching is character-wise). End the filter with `/` to bound it to child scopes.
3. **Events with a null scope ARE delivered** to scope-filtered subscriptions (a null scope matches every filter), rather than being excluded.
**Recommendation:** write filters in the spec's `/*` form. It matches runtime-emitted events on every version and both planes on admin 0.1.25.49+ / runtime 0.1.25.47+. There is no single filter form that matches child scopes on both planes on admin 0.1.25.48 and earlier: `/*` misses admin events, bare-prefix misses runtime events. If you must catch both on those older versions, subscribe without a `scope_filter` and filter client-side on the envelope `scope`. Upgrading to 0.1.25.49 is a **behavior change** for existing prefix-style filters — see the [release's migration notes](https://github.com/runcycles/cycles-server-admin/releases/tag/v0.1.25.49) (bare-prefix filters must be rewritten as `…/*`; "base + descendants" coverage now needs two subscriptions).
:::
## Matching rules (spec semantics — normative)
Per the admin OpenAPI spec, the scope filter supports two modes:
### Exact match (no wildcard)
The event scope must exactly equal the filter string.
```json
{
"scope_filter": "tenant:acme-corp/workspace:prod"
}
```
Under spec semantics this delivers events **only** when the event scope is exactly `tenant:acme-corp/workspace:prod`; events scoped to `tenant:acme-corp/workspace:prod/workflow:support` would **not** match. **Admin plane, 0.1.25.48 and earlier:** this filter is treated as a prefix, so child-scope admin events *do* match (consequence 2 above). The runtime matcher applies the exact-match spec semantics.
### Prefix match (trailing wildcard)
A filter ending with `*` matches any event scope that starts with the prefix before the `*`.
```json
{
"scope_filter": "tenant:acme-corp/*"
}
```
Under spec semantics this delivers events for any scope under `tenant:acme-corp/`, including:
- `tenant:acme-corp/workspace:prod`
- `tenant:acme-corp/workspace:prod/workflow:support`
- `tenant:acme-corp/workspace:staging/agent:bot-1`
**Per plane (0.1.25.48 and earlier):** the runtime matcher handles this correctly; the admin matcher compares the `*` literally and delivers nothing (consequence 1 above).
### No filter (default)
If `scope_filter` is null, empty, or not provided, the subscription matches **all events** regardless of scope. Both semantics agree on this.
```json
{
"scope_filter": null
}
```
## Syntax summary
| Filter | Spec semantics (normative; both planes on admin 0.1.25.49+ / runtime 0.1.25.47+) | Admin plane, 0.1.25.48 and earlier (prefix match) |
|---|---|---|
| `null` / empty / blank | All events | All events |
| `tenant:acme-corp` | Only scope exactly `tenant:acme-corp` | Any scope starting with `tenant:acme-corp` (including `tenant:acme-corpX`) |
| `tenant:acme-corp/` | Only scope exactly `tenant:acme-corp/` (unlikely to exist) | Any scope starting with `tenant:acme-corp/` |
| `tenant:acme-corp/*` | Scopes **under** `tenant:acme-corp/` — a non-empty child segment is required from cycles-server 0.1.25.47 onward (the degenerate empty-child scope `tenant:acme-corp/` matched on 0.1.25.46 and earlier) | Nothing (literal `*` never appears in real scopes) |
| `tenant:acme-corp/workspace:prod` | Only that exact scope | That scope and anything starting with it |
| `*` | Undefined by spec; the runtime matcher treats it as an empty-prefix trailing wildcard — any non-blank scope matches from cycles-server 0.1.25.47 onward (on 0.1.25.46 and earlier a blank `""` scope also matched, via the empty-prefix comparison) | All events (including null-scope) |
| *(any filter)* vs. null-scope event | Not delivered | Delivered |
## What's NOT supported
- **Mid-string wildcards** — `tenant:*/workspace:prod` does not work. In the spec, `*` is only meaningful at the end of the filter string; a mid-string `*` is treated as a literal character on both planes (and the pre-fix admin matcher treats even a trailing `*` as literal).
- **Multiple wildcards** — `tenant:acme-corp/*/workflow:*` is not valid.
- **Regex** — no regular expression matching is supported.
- **Glob patterns** — `?`, `[a-z]`, and other glob characters are treated as literal characters.
- **Multiple scope filters per subscription** — each subscription has a single `scope_filter` string. Create multiple subscriptions if you need to watch multiple unrelated scopes.
Under spec semantics — and the runtime matcher — a `*` anywhere other than the end of the filter string is treated as a **literal character** in an exact-match comparison (which almost certainly won't match any real scope). The pre-fix admin matcher (0.1.25.48 and earlier) treats every `*` as literal, including a trailing one.
## Examples
The examples below use the spec `/*` form — correct for runtime-emitted events on every version, and for both planes on cycles-server-admin 0.1.25.49+ / cycles-server 0.1.25.47+. (On admin 0.1.25.48 and earlier, admin-plane events will not match these filters; see the callout above.)
### Subscribe to all events for one tenant
A subscription must match on at least one selector, so at least one of `event_types` / `event_categories` must be non-empty — the server rejects the empty-both state with `400 INVALID_REQUEST` (governance revision v0.1.25.39; enforced since cycles-server-admin 0.1.25.50). The two arrays are additive (union) in delivery matching. Note the create/update asymmetry: `POST /v1/admin/webhooks` (and `/v1/webhooks`) requires a non-empty `event_types` specifically, while `PATCH` may clear `event_types` to empty as long as `event_categories` is non-empty — a **category-only** subscription is valid on update. To cover whole categories on create, pair a representative type with the category list. See [Category-based subscriptions](/how-to/managing-webhooks#category-based-subscriptions).
```bash
curl -X POST http://localhost:7979/v1/admin/webhooks \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://ops.example.com/cycles-events",
"event_types": ["budget.exhausted"],
"event_categories": ["budget", "reservation", "tenant"],
"scope_filter": "tenant:acme-corp/*"
}'
```
This covers the **tenant-accessible** classes (`budget` / `reservation` / `tenant`) for one tenant. It omits the admin-only categories: `api_key.*`, `webhook.*`, and `system.*` events are **null-scoped**, so a `scope_filter` excludes them — you can't narrow those to one tenant with `scope_filter` (filter client-side on the envelope `tenant_id` instead; see [Tenant-accessible events](/protocol/webhook-event-delivery-protocol#tenant-accessible-events)). The one admin category that *is* scope-filterable is `policy.*` (it carries a real tenant-bounded scope) — add `policy` to `event_categories` if you also want this tenant's policy events. Note this is a `__system__`-owned subscription (admin key, no `tenant_id`); a **tenant-owned** subscription can't carry admin-only categories at all.
### Subscribe to one specific workspace
```bash
curl -X POST http://localhost:7979/v1/admin/webhooks \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://ops.example.com/prod-alerts",
"event_types": ["budget.exhausted", "reservation.denied"],
"scope_filter": "tenant:acme-corp/workspace:prod/*"
}'
```
This delivers only `budget.exhausted` and `reservation.denied` events (runtime-emitted) where the scope starts with `tenant:acme-corp/workspace:prod/`.
### No scope filter — receive everything
```bash
curl -X POST http://localhost:7979/v1/admin/webhooks \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://ops.example.com/all-events",
"event_types": ["budget.exhausted"],
"event_categories": ["budget", "reservation", "tenant", "api_key", "policy", "webhook", "system"]
}'
```
`scope_filter` omitted — this subscription receives matching events from all scopes (including unscoped events). All-categories `event_categories` plus a representative `event_types` entry is the "everything" form on **create**, where `event_types` must be non-empty; on a later `PATCH` the type could be cleared, leaving the all-categories subscription category-only. The two arrays are a union in delivery matching. This is a `__system__`-owned subscription (admin key, no `tenant_id`), so the admin-only categories are permitted; a **tenant-owned** subscription (`/v1/webhooks`, or `/v1/admin/webhooks?tenant_id=X`) may carry only `budget` / `reservation` / `tenant` (governance INVARIANT 2 — see [Tenant-accessible events](/protocol/webhook-event-delivery-protocol#tenant-accessible-events)).
### Combining event type filter with scope filter
Both filters apply with AND logic. An event must match **both** the event type list and the scope filter to be delivered.
```bash
curl -X POST http://localhost:7979/v1/admin/webhooks \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://ops.example.com/cost-alerts",
"event_types": ["budget.exhausted", "budget.over_limit_entered"],
"scope_filter": "tenant:acme-corp/workspace:prod/*"
}'
```
This delivers only `budget.exhausted` **or** `budget.over_limit_entered` events (runtime-emitted) **and** only when the scope starts with `tenant:acme-corp/workspace:prod/`.
## Events without scope
Some events may not have a `scope` field (null). The two semantics differ:
- **Spec semantics (normative):** when `scope_filter` is set and an event has a null scope, the event is **not delivered** to that subscription. Use a separate subscription without a scope filter to capture unscoped events.
- **Per plane:** the runtime matcher follows the spec (null scope excluded from filtered subscriptions — `EventEmitterRepository.matchesScope`); the admin plane on 0.1.25.48 and earlier delivers null-scope events to every filter (fixed in cycles-server-admin 0.1.25.49).
::: tip Note on `reservation.commit_overage`
As of cycles-server v0.1.25.46, `reservation.commit_overage` is emitted **with** the reservation's scope path on the envelope, so it participates in scope filtering like any other scoped event. (Earlier releases emitted it with a null envelope scope, in which case the null-scope rules above applied.)
:::
## Edge cases
- **Whitespace-only filter** (e.g., `" "`): Treated the same as null — matches all events (both semantics).
- **Filter `"*"` alone**: Under spec semantics this is undefined (arguably an exact match against a scope literally equal to `*`). The runtime matcher treats it as "match any scoped event": from cycles-server 0.1.25.47 onward, blank/whitespace-only scopes are treated as unscoped and excluded — the same semantics as the admin matcher since cycles-server-admin 0.1.25.49; on runtime 0.1.25.46 and earlier, a blank `""` scope still matched via the empty-prefix comparison. Only the admin plane on 0.1.25.48 and earlier matches **all** events including null-scope ones. Prefer omitting `scope_filter` entirely to mean "everything".
- **Blank event scopes** (`""` or whitespace-only): From cycles-server 0.1.25.47 (runtime) and cycles-server-admin 0.1.25.49 onward, blank and null scopes are both treated as unscoped — excluded from every scope-filtered subscription. On runtime 0.1.25.46 and earlier, only `null` was checked (a blank scope could match the bare `*` filter); on admin 0.1.25.48 and earlier, null-scope events match every filter.
- **Empty-child scopes against trailing `/*`** (e.g., event scope `tenant:acme-corp/` against filter `tenant:acme-corp/*`): From cycles-server 0.1.25.47 onward, the runtime matcher requires a non-empty remainder after the prefix — `tenant:acme-corp/` no longer matches (the spec says "all scopes **under** acme-corp"). On 0.1.25.46 and earlier it matched via plain `startsWith`. Unchanged on both: the bare base scope `tenant:acme-corp` (no trailing slash) never matches a `…/*` filter on the runtime plane.
## Related
- [Managing Webhooks](/how-to/managing-webhooks) — creating, updating, and testing subscriptions
- [Webhook Event Delivery Protocol](/protocol/webhook-event-delivery-protocol) — delivery mechanics, retry schedule, signatures
- [Event Payloads Reference](/protocol/event-payloads-reference) — payload schemas for all event types
---
# How-To Guides
# Add Cycles with Claude, Codex, Cursor, or Windsurf
This page is for engineers who want their AI coding assistant to integrate Cycles into an existing codebase. Open Claude Code (or Codex, Cursor, Windsurf) in your repo, paste the prompt below, and let it wire one budget-enforced boundary, with a test that proves enforcement, in a single session.
**The invariant: Cycles must run before the costly action on the same execution path.** Every rule, example, and test on this page exists to prove or enforce that one statement.
::: tip If you only need MCP host setup
For Claude Desktop / Claude Code / Cursor / Windsurf MCP server config, see the per-host quickstarts: [Claude Desktop](/quickstart/mcp-claude-desktop) · [Claude Code](/quickstart/mcp-claude-code) · [Cursor](/quickstart/mcp-cursor) · [Windsurf](/quickstart/mcp-windsurf). MCP gives the assistant access to Cycles tools — it does not by itself enforce budgets in your application's execution path. See [MCP vs enforcement](#mcp-availability-is-not-enforcement) below.
:::
## The integration contract
Hand this contract to the assistant. It is the do/don't list that turns "integrate Cycles" from an open-ended task into a bounded one.
```md
You are integrating Cycles into an existing agent codebase.
Goal:
Add pre-execution budget/action enforcement around one LLM call or tool call.
Prove enforcement with a test, then expand.
Do:
1. Identify model calls and external side-effect tool calls in this repo.
2. Pick ONE boundary to wrap first. Highest cost, highest frequency, or
highest blast radius wins.
3. Use the language-specific client:
- Python: `runcycles` package, `@cycles` decorator
- TypeScript: `runcycles` package, `withCycles` HOF
- Java/Spring: `cycles-client-java-spring`, `@Cycles` annotation
- Rust: `runcycles` crate, `with_cycles()` (auto) or
`ReservationGuard` (manual / streaming)
4. Read configuration from environment:
- CYCLES_BASE_URL (e.g. http://localhost:7878)
- CYCLES_API_KEY (cyc_live_... — issued by the Cycles Admin Server)
- CYCLES_TENANT (e.g. acme-corp)
5. Add a graceful denial fallback: when budget is denied, do NOT execute the
downstream call. Return a fallback, downgrade to a cheaper model, or queue.
6. Add ONE test that proves the downstream call is not invoked when Cycles
denies. Mock the model client; assert it received zero calls on DENY.
Do not:
- Rely on prompts as enforcement.
- Add only logging without a deny path.
- Place the Cycles check after the downstream call.
- Treat MCP tool availability as hard enforcement. The MCP server exposes
Cycles tools to the assistant; it does not gate the application's own
execution path. Production enforcement belongs in the SDK wrapper or
gateway, not in the host's tool list.
- Invent new patterns. Use the decorator / HOF / annotation as documented.
- Wrap more than one boundary in the first pass. Ship one, test it, then
expand.
Success test:
- One LLM (or external tool) call wrapped with the language-appropriate
Cycles primitive.
- Env vars read via `CyclesConfig.from_env()` / `CyclesConfig.fromEnv()` /
Spring properties.
- One test asserting the downstream client is not called on budget denial.
- The change is a small diff — ideally under ~50 lines of production code
plus the test. Spring and Rust may run slightly longer.
```
## Definition of done
The integration is complete when **all** of these are true. This is the checklist Claude/Codex should optimize toward — and that you should grade the diff against before merging.
- [ ] At least one LLM or external tool call is wrapped with the Cycles primitive.
- [ ] The Cycles `reserve` (or decorator/HOF/annotation entry) runs **before** the downstream call.
- [ ] On `DENY`, the downstream call is **skipped** entirely. No model API request fires.
- [ ] On success, **actual usage is committed** (not just the original estimate).
- [ ] On thrown exception, the reservation is **released** so budget returns to the pool.
- [ ] One test mocks the downstream client and asserts it received zero calls on DENY.
- [ ] `CYCLES_BASE_URL`, `CYCLES_API_KEY`, `CYCLES_TENANT` are read from env, not hardcoded.
If any item is unchecked, the integration is not done — even if the happy path runs.
## Copy this prompt into your assistant
Paste this verbatim into Claude Code, Codex, Cursor, or Windsurf at the root of your repo:
```md
Integrate Cycles into this repo.
First inspect the codebase and identify:
- all LLM calls (OpenAI, Anthropic, Bedrock, Gemini, Groq, Ollama, etc.)
- all tool calls with external cost or real-world side effects
- tenant / user / run identifiers already available in request context
Then implement the smallest safe integration:
- wrap ONE LLM call with Cycles
- reserve budget before execution
- commit actual usage after execution
- release on failure
- deny BEFORE the downstream call when the budget is exhausted
- read CYCLES_BASE_URL, CYCLES_API_KEY, CYCLES_TENANT from environment
- add ONE test proving the downstream call is not made on DENY
Follow the integration contract and constraints from:
https://runcycles.io/how-to/add-cycles-with-claude-or-codex
Use the official docs:
- Quickstart: https://runcycles.io/quickstart/end-to-end-tutorial
- Existing-app integration: https://runcycles.io/how-to/adding-cycles-to-an-existing-application
- Python client: https://runcycles.io/quickstart/getting-started-with-the-python-client
- TypeScript client: https://runcycles.io/quickstart/getting-started-with-the-typescript-client
- Spring Boot starter: https://runcycles.io/quickstart/getting-started-with-the-cycles-spring-boot-starter
- Rust client: https://runcycles.io/quickstart/getting-started-with-the-rust-client
- Error handling (Python): https://runcycles.io/how-to/error-handling-patterns-in-python
- Error handling (TS): https://runcycles.io/how-to/error-handling-patterns-in-typescript
- Error handling (Rust): https://runcycles.io/how-to/error-handling-patterns-in-rust
Pick the language guide that matches this repo. Do NOT wrap more than one
boundary in this pass. Stop and report when the test passes.
```
The full machine-readable index is at /llms.txt — most assistants can fetch it to discover the rest of the documentation.
## Drop-in AGENTS.md / CLAUDE.md / .cursorrules snippet
To make the integration recipe survive across sessions, drop this into your repo's `AGENTS.md`, `CLAUDE.md`, or `.cursorrules` file. Future AI sessions inherit the rules without you re-pasting the prompt.
The snippet is published as a static file you can curl:
```bash
curl -O https://runcycles.io/agents/cycles-integration.md
# then append or include in your repo's AGENTS.md / CLAUDE.md / .cursorrules
```
The same content is mirrored at /agents/cycles-integration.md (opens in a new tab — the file is served as raw markdown, not a VitePress page).
## Minimum viable integration by language
The assistant should produce a diff that looks like one of the four blocks below. These are lifted directly from the language quickstarts — there is no new pattern here.
::: code-group
```python [Python]
# pip install runcycles
# env: CYCLES_BASE_URL, CYCLES_API_KEY, CYCLES_TENANT
from runcycles import (
CyclesClient, CyclesConfig, BudgetExceededError, cycles, set_default_client,
)
set_default_client(CyclesClient(CyclesConfig.from_env()))
def estimate_actual(summary: str) -> int:
# Prefer provider usage/cost metadata (e.g. response.usage.total_tokens)
# when available. This length-based placeholder keeps the example short.
return max(1, len(summary) * 5) # USD_MICROCENTS
@cycles(
estimate=2_000_000, # USD_MICROCENTS — tune from logs
actual=estimate_actual,
action_kind="llm.completion",
action_name="openai:gpt-4o",
)
def generate_summary(document: str) -> str:
return openai.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": f"Summarize: {document}"}],
max_tokens=2000,
).choices[0].message.content
def summarize_or_fallback(document: str) -> str:
try:
return generate_summary(document)
except BudgetExceededError:
return "Summary unavailable — budget limit reached."
```
```typescript [TypeScript]
// npm install runcycles
// env: CYCLES_BASE_URL, CYCLES_API_KEY, CYCLES_TENANT
import {
CyclesClient, CyclesConfig, BudgetExceededError,
withCycles, setDefaultClient,
} from "runcycles";
setDefaultClient(new CyclesClient(CyclesConfig.fromEnv()));
const generateSummary = withCycles(
{
estimate: 2_000_000, // USD_MICROCENTS — tune from logs
actual: (summary: string) => Math.max(1, summary.length * 5),
actionKind: "llm.completion",
actionName: "openai:gpt-4o",
},
async (document: string) => {
const r = await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: `Summarize: ${document}` }],
max_tokens: 2000,
});
return r.choices[0].message.content!;
},
);
export async function summarizeOrFallback(document: string) {
try {
return await generateSummary(document);
} catch (err) {
if (err instanceof BudgetExceededError) {
return "Summary unavailable — budget limit reached.";
}
throw err;
}
}
```
```java [Java / Spring]
// pom.xml: io.runcycles:cycles-client-java-spring
// application.yml: cycles.base-url, cycles.api-key, cycles.tenant
import io.runcycles.client.java.spring.annotation.Cycles;
import io.runcycles.client.java.spring.model.CyclesProtocolException;
@Service
public class SummaryService {
@Cycles(value = "2000000",
// Adapt to your client's response shape — the SpEL expression
// must resolve against whatever generateSummary() returns.
actual = "#result.usage.totalTokens * 8",
actionKind = "llm.completion",
actionName = "openai:gpt-4o")
public ChatResponse generateSummary(String document) {
return openAiClient.chat(document);
}
public String summarizeOrFallback(String document) {
try {
return generateSummary(document).text();
} catch (CyclesProtocolException e) {
if (e.isBudgetExceeded()) {
return "Summary unavailable — budget limit reached.";
}
throw e;
}
}
}
```
```rust [Rust]
// Cargo.toml: runcycles = "0.3"
// env: CYCLES_BASE_URL, CYCLES_API_KEY, CYCLES_TENANT
use runcycles::{
with_cycles, CyclesClient, CyclesConfig, Error, WithCyclesConfig,
models::{Amount, Subject},
};
pub async fn summarize_or_fallback(
client: &CyclesClient,
document: &str,
) -> String {
let result = with_cycles(
client,
WithCyclesConfig::new(Amount::usd_microcents(2_000_000))
.action("llm.completion", "openai:gpt-4o")
.subject(Subject { tenant: Some("acme-corp".into()), ..Default::default() }),
|_ctx| async move {
let summary = call_openai(document).await?;
let actual = Amount::usd_microcents(estimate_actual(&summary));
Ok((summary, actual))
},
).await;
match result {
Ok(summary) => summary,
Err(Error::BudgetExceeded { .. }) => {
"Summary unavailable — budget limit reached.".into()
}
Err(e) => panic!("unexpected Cycles error: {e}"),
}
}
```
:::
## The most common wrong integration
AI coding assistants frequently produce a plausible-but-wrong integration that *looks* like it's using Cycles, but enforces nothing. It records spend after the fact instead of authorizing it before the fact.
The blocks below show the structural difference using the real TypeScript SDK surface (`client.createReservation`, `client.commitReservation`). For most repos you should reach for `withCycles` from the language picker above — it does this lifecycle for you.
```typescript
// WRONG — calls OpenAI first, then reports usage to Cycles.
// This is observability, not enforcement. The model call has already
// happened and the money is already spent. DENY is meaningless here.
import { CyclesClient, CyclesConfig } from "runcycles";
const client = new CyclesClient(CyclesConfig.fromEnv());
const result = await openai.chat.completions.create({ /* ... */ });
await client.commitReservation("rsv_...", {
actual: { amount: 35000, unit: "USD_MICROCENTS" },
});
```
```typescript
// RIGHT — reserve first, execute only on ALLOW, commit actuals after.
// On DENY the OpenAI call never fires.
import { CyclesClient, CyclesConfig } from "runcycles";
const client = new CyclesClient(CyclesConfig.fromEnv());
const reservation = await client.createReservation({
idempotencyKey: crypto.randomUUID(),
subject: { tenant: "acme-corp" },
action: { kind: "llm.completion", name: "openai:gpt-4o" },
estimate: { amount: 50000, unit: "USD_MICROCENTS" },
});
if (reservation.decision !== "ALLOW") {
return fallback();
}
const result = await openai.chat.completions.create({ /* ... */ });
await client.commitReservation(reservation.reservationId, {
actual: { amount: 35000, unit: "USD_MICROCENTS" },
});
```
The decorator / HOF / annotation in the language picker above does the reserve → check → execute → commit flow for you. Drop to the programmatic `CyclesClient` only when you need streaming, multi-step lifecycles, or a gateway integration. If a generated diff has the model call running before the Cycles primitive, it is wrong — reject it.
## Where to place Cycles in your architecture
The right insertion point depends on how the agent is structured. Hand the assistant this table along with the rest of the contract.
| Situation | Put Cycles here |
| ------------------------------- | ---------------------------------------------------------- |
| Direct OpenAI / Anthropic call | SDK wrapper around the model call |
| Tool-calling agent | Tool execution wrapper, before the tool runs |
| Multi-tenant SaaS agent | Tenant / workflow boundary before each costly action |
| MCP-based local assistant | MCP for local discovery; runtime wrapper for production |
| Gateway / proxy architecture | Gateway, before the downstream model or tool call |
| Batch / scheduled job | Job entry point, around the per-item action |
The constant: **the Cycles check is on the same code path as the costly action, and runs before it.** Anywhere else is logging, not enforcement.
## Success test: prove the downstream call is not made on DENY
This is the test the assistant must produce. It is the only thing that proves the integration is doing its job.
Mock Cycles so the guard returns DENY; do **not** mock the wrapped function itself. Replacing `generate_summary` / `generateSummary` bypasses the Cycles guard and only tests fallback handling.
::: code-group
```python [Python — pytest]
import importlib
from unittest.mock import MagicMock
def test_openai_not_called_when_budget_denied(monkeypatch, httpx_mock):
monkeypatch.setenv("CYCLES_BASE_URL", "http://cycles.test")
monkeypatch.setenv("CYCLES_API_KEY", "test-key")
monkeypatch.setenv("CYCLES_TENANT", "acme-corp")
httpx_mock.add_response(
method="POST",
url="http://cycles.test/v1/reservations",
status_code=409,
json={
"error": "BUDGET_EXCEEDED",
"message": "budget exhausted",
"request_id": "req_test_123",
},
)
import myapp.summary as summary
summary = importlib.reload(summary) # rebuild CyclesConfig.from_env() with test env
fake_openai = MagicMock()
monkeypatch.setattr(summary, "openai", fake_openai)
result = summary.summarize_or_fallback("a document")
assert "budget limit reached" in result
fake_openai.chat.completions.create.assert_not_called()
```
```typescript [TypeScript — vitest]
import { afterEach, describe, it, expect, vi } from "vitest";
function mockCyclesDeny() {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
status: 409,
statusText: "Conflict",
json: () => Promise.resolve({
error: "BUDGET_EXCEEDED",
message: "budget exhausted",
request_id: "req_test_123",
}),
headers: new Headers(),
}),
);
}
describe("summarizeOrFallback", () => {
afterEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
});
it("does not call OpenAI when budget is denied", async () => {
process.env.CYCLES_BASE_URL = "http://cycles.test";
process.env.CYCLES_API_KEY = "test-key";
process.env.CYCLES_TENANT = "acme-corp";
mockCyclesDeny();
const create = vi.fn();
vi.doMock("./openai-client", () => ({ openai: { chat: { completions: { create } } } }));
const { summarizeOrFallback } = await import("./summary");
const result = await summarizeOrFallback("a document");
expect(result).toMatch(/budget limit reached/);
expect(create).not.toHaveBeenCalled();
});
});
```
```rust [Rust — test shape, adapt to your harness]
// Sketch only — adapt to your test harness. The Python and TypeScript tests
// above are runnable; this one outlines the equivalent structure.
//
// Use a mock CyclesClient that returns DENY (e.g. wiremock or a hand-rolled
// fake server). Inject a fake `call_openai` that increments a counter, and
// assert the counter is still zero after summarize_or_fallback returns the
// fallback string.
#[tokio::test]
async fn openai_not_called_when_budget_denied() {
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let client = mock_cycles_client_returning_deny().await;
let result = summarize_or_fallback_with_injected_caller(
&client,
"a document",
{
let calls = calls.clone();
move |_doc| { calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { unreachable!() } }
},
).await;
assert!(result.contains("budget limit reached"));
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
}
```
:::
If the assistant cannot make this test pass, the integration is wrong — the Cycles check is in the wrong place, or there is no deny path. Iterate until it passes.
## MCP availability is not enforcement
If your repo runs inside [Claude Desktop](/quickstart/mcp-claude-desktop), [Claude Code](/quickstart/mcp-claude-code), [Cursor](/quickstart/mcp-cursor), or [Windsurf](/quickstart/mcp-windsurf), registering the Cycles MCP server gives the host access to `cycles_reserve`, `cycles_commit`, `cycles_release`, and balance tools.
**MCP is useful for local assistant workflows and discovery. It is not, by itself, a hard runtime control unless the host or tool harness is required to call Cycles before executing the real action.** For production, the Cycles check must sit in the execution path — the SDK wrapper, gateway, or framework adapter — where the costly action cannot run without it. See [Integrating Cycles with MCP](/how-to/integrating-cycles-with-mcp) for the patterns that combine MCP discovery with hard enforcement.
## After the first wrap
Once the test passes:
1. Move to [shadow mode](/how-to/shadow-mode-in-cycles-how-to-roll-out-budget-enforcement-without-breaking-production) to observe decisions in production without blocking.
2. [Expand coverage](/how-to/adding-cycles-to-an-existing-application#stage-3-expand-coverage) to additional call paths.
3. Switch from shadow to live enforcement.
4. Wire the broader [reserve / commit / release lifecycle](/protocol/how-reserve-commit-works-in-cycles) — dynamic estimates, tenant scoping, run budgets.
The assistant has the contract; you have the test. Ship one boundary, prove it, expand.
## Protocol references (only if you need them)
Most integrations never touch the protocol directly — the decorator, HOF, and annotation hide it. Reach for these only when you need an exact field name, are debugging an error code, or are wrapping an HTTP call by hand because no SDK exists for your language. Linking these here so an AI coder can resolve the rare leak without guessing field names.
- [Reserve / commit lifecycle](/protocol/how-reserve-commit-works-in-cycles) — request/response shape for `reserve`, `commit`, `release`, plus `idempotencyKey`, `ttlMs`, and what each call returns.
- [Decide endpoint](/protocol/how-decide-works-in-cycles-preflight-budget-checks-without-reservation) — preflight check without holding budget. The right endpoint for shadow mode and "would this be allowed?" reads.
- [Error codes](/protocol/error-codes-and-error-handling-in-cycles) — canonical list (`BUDGET_EXCEEDED`, `RESERVATION_NOT_FOUND`, `INVALID_IDEMPOTENCY_KEY`, etc.). Map directly to `BudgetExceededError` / `CyclesProtocolException` in the SDKs.
- [Units (USD_MICROCENTS, TOKENS, CREDITS, RISK_POINTS)](/protocol/understanding-units-in-cycles-usd-microcents-tokens-credits-and-risk-points) — what `unit` and `amount` mean. The SDK examples on this page use `USD_MICROCENTS`; pick the right unit per ledger.
- [Caps and the three-way decision model](/protocol/caps-and-the-three-way-decision-model-in-cycles) — `ALLOW`, `ALLOW_WITH_CAPS`, `DENY`. If your wrapper handles only ALLOW/DENY, check this before assuming caps are safe to ignore.
- [Interactive OpenAPI reference](/api/) — full schema browser. The full spec is at `/cycles-protocol-v0.yaml`.
If you are an AI coding assistant: prefer the SDK-level integration on this page. Drop to protocol level only when the SDK genuinely doesn't expose what you need.
# Adding Cycles to an Existing Application
This guide covers how to incrementally add budget governance to an application that already makes LLM or API calls. Rather than rewriting your integration layer, you can adopt Cycles in stages.
::: tip MCP-compatible agents
If your agent runs in Claude Desktop, Claude Code, Cursor, or Windsurf, the fastest path is the [Cycles MCP Server](/quickstart/getting-started-with-the-mcp-server) — zero code changes needed. The guide below covers SDK-based integration for application code.
:::
::: tip Spring Boot / Java
This guide shows Python and TypeScript. For Spring Boot, equivalent patterns use the `@Cycles` annotation — see the [Spring Boot Quickstart](/quickstart/getting-started-with-the-cycles-spring-boot-starter) for full setup and examples.
:::
## The incremental adoption path
```
1. Shadow mode → Observe what enforcement would do, without blocking anything
2. Wrap one call → Add budget governance to a single LLM call path
3. Expand coverage → Wrap additional call paths
4. Enforce → Switch from shadow mode to live enforcement
```
## Stage 1: Deploy Cycles and observe with the decide endpoint
Start by deploying the Cycles stack and using the **decide endpoint** as a side-channel to observe what budget decisions would be made without blocking any calls.
::: warning Important
The `dry_run` flag on the `@cycles` decorator / `withCycles` HOF skips executing the wrapped function entirely (it returns a `DryRunResult` instead). For shadow observation where your existing code still runs, use the `decide` endpoint separately.
:::
::: code-group
```python [Python]
import logging
import uuid
from runcycles import CyclesClient, CyclesConfig
logger = logging.getLogger(__name__)
config = CyclesConfig.from_env()
client = CyclesClient(config)
def existing_chat_function(prompt: str) -> str:
# Observe: check what Cycles would decide, but don't block
try:
decision = client.decide({
"idempotency_key": str(uuid.uuid4()),
"subject": {"tenant": config.tenant},
"action": {"kind": "llm.completion", "name": "openai:gpt-4o"},
"estimate": {"amount": 2000000, "unit": "USD_MICROCENTS"},
})
logger.info("Cycles decision: %s", decision.get_body_attribute("decision"))
except Exception:
pass # Don't let observation failures affect production
# Your existing code — completely unchanged
return call_openai(prompt)
```
```typescript [TypeScript]
import { CyclesClient, CyclesConfig } from "runcycles";
const client = new CyclesClient(CyclesConfig.fromEnv());
async function existingChatFunction(prompt: string) {
// Observe: check what Cycles would decide, but don't block
try {
// The client posts wire-format (snake_case) bodies verbatim
const decision = await client.decide({
idempotency_key: crypto.randomUUID(),
subject: { tenant: client.config.tenant! },
action: { kind: "llm.completion", name: "openai:gpt-4o" },
estimate: { amount: 2000000, unit: "USD_MICROCENTS" },
});
console.log("Cycles decision:", decision.body?.decision);
} catch {
// Don't let observation failures affect production
}
// Your existing code — completely unchanged
return await callOpenAI(prompt);
}
```
:::
This approach lets you observe decisions in production logs while your existing code continues to run unmodified. Use the data to tune budgets before moving to enforcement.
See [Shadow Mode Rollout](/how-to/shadow-mode-in-cycles-how-to-roll-out-budget-enforcement-without-breaking-production) for the full guide on dry-run evaluation at the protocol level.
## Stage 2: Wrap your first call
Pick the highest-value call path to wrap first. Good candidates:
- The call that costs the most per invocation (e.g., GPT-4o or Claude Opus)
- The call that runs most frequently
- The call most likely to loop or retry
### Wrapping an existing function
**Before:**
::: code-group
```python [Python]
def generate_summary(document: str) -> str:
response = openai.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": f"Summarize: {document}"}],
max_tokens=2000,
)
return response.choices[0].message.content
```
```typescript [TypeScript]
async function generateSummary(document: string): Promise {
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: `Summarize: ${document}` }],
max_tokens: 2000,
});
return response.choices[0].message.content!;
}
```
:::
**After:**
::: code-group
```python [Python]
from runcycles import cycles
@cycles(
estimate=lambda document: int((len(document) / 4 * 250 + 2000 * 1000) * 1.2),
action_kind="llm.completion",
action_name="openai:gpt-4o",
)
def generate_summary(document: str) -> str:
response = openai.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": f"Summarize: {document}"}],
max_tokens=2000,
)
return response.choices[0].message.content
```
```typescript [TypeScript]
import { withCycles } from "runcycles";
const generateSummary = withCycles(
{
estimate: (document: string) => Math.ceil((document.length / 4 * 250 + 2000 * 1000) * 1.2),
actionKind: "llm.completion",
actionName: "openai:gpt-4o",
},
async (document: string) => {
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: `Summarize: ${document}` }],
max_tokens: 2000,
});
return response.choices[0].message.content!;
},
);
```
```java [Spring Boot]
import io.runcycles.client.java.spring.annotation.Cycles;
// The estimate is a raw SpEL expression — no #{...} template wrapper
@Cycles(value = "T(Math).ceil((#document.length() / 4 * 250 + 2000 * 1000) * 1.2)",
actionKind = "llm.completion", actionName = "openai:gpt-4o")
public String generateSummary(String document) {
// Same OpenAI call — business logic unchanged
return openAiClient.chat(document);
}
```
:::
The only change is adding the `@cycles` decorator (Python), `withCycles` wrapper (TypeScript), or `@Cycles` annotation (Spring Boot). Your business logic stays exactly the same.
### Handling budget denial
Your existing error handling needs one new branch — what to do when budget is denied:
::: code-group
```python [Python]
from runcycles import BudgetExceededError
try:
result = generate_summary(document)
except BudgetExceededError:
# Option A: Return a graceful fallback
result = "Summary unavailable — budget limit reached."
# Option B: Use a cheaper model
result = generate_summary_cheap(document)
# Option C: Queue for later
queue_for_retry(document)
```
```typescript [TypeScript]
import { BudgetExceededError } from "runcycles";
let result: string;
try {
result = await generateSummary(document);
} catch (err) {
if (!(err instanceof BudgetExceededError)) {
throw err;
}
// Option A: Return a graceful fallback
result = "Summary unavailable — budget limit reached.";
// Option B: Use a cheaper model
result = await generateSummaryCheap(document);
// Option C: Queue for later
queueForRetry(document);
}
```
```java [Spring Boot]
import io.runcycles.client.java.spring.model.CyclesProtocolException;
String result;
try {
result = summaryService.generateSummary(document);
} catch (CyclesProtocolException e) {
if (!e.isBudgetExceeded()) {
throw e;
}
// Option A: Return a graceful fallback
result = "Summary unavailable — budget limit reached.";
// Option B: Use a cheaper model
result = summaryService.generateSummaryCheap(document);
// Option C: Queue for later
queueForRetry(document);
}
```
:::
See [Degradation Paths](/how-to/how-to-think-about-degradation-paths-in-cycles-deny-downgrade-disable-or-defer) for a full treatment of fallback strategies.
## Stage 3: Expand coverage
Once the first call path is working, wrap additional calls. Use a consistent pattern:
::: code-group
```python [Python]
@cycles(estimate=500000, action_kind="llm.completion", action_name="openai:gpt-4o-mini")
def classify_intent(text: str) -> str:
...
@cycles(estimate=3000000, action_kind="llm.completion", action_name="openai:gpt-4o")
def generate_response(context: str, intent: str) -> str:
...
@cycles(estimate=100000, action_kind="tool.call", action_name="web-search")
def search_web(query: str) -> list:
...
```
```typescript [TypeScript]
const classifyIntent = withCycles(
{ estimate: 500000, actionKind: "llm.completion", actionName: "openai:gpt-4o-mini" },
async (text: string) => { ... },
);
const generateResponse = withCycles(
{ estimate: 3000000, actionKind: "llm.completion", actionName: "openai:gpt-4o" },
async (context: string, intent: string) => { ... },
);
const searchWeb = withCycles(
{ estimate: 100000, actionKind: "tool.call", actionName: "web-search" },
async (query: string) => { ... },
);
```
:::
Each wrapped function reserves independently. If the agent calls all three in sequence, the total budget consumed is the sum of actual usage — and each call is individually authorized before it runs.
## Stage 4: Switch to live enforcement
Wrapped calls enforce immediately unless `dry_run` is set — so if you followed Stages 1–3 as written, your wrapped paths are already live, and Stage 4 simply means retiring the Stage 1 `decide` side-channel once you're confident in your budget allocations. If you did set `dry_run=True` on any wrapped call during testing (recall from Stage 1 that it skips executing the wrapped function, so it's unsuitable for shadow-observing production traffic), remove it now:
::: code-group
```python [Python]
# Remove dry_run to enable enforcement
@cycles(
estimate=2000000,
action_kind="llm.completion",
action_name="openai:gpt-4o",
# dry_run=True, ← remove this line
)
def generate_summary(document: str) -> str:
...
```
```typescript [TypeScript]
// Remove dryRun to enable enforcement
const generateSummary = withCycles(
{
estimate: 2000000,
actionKind: "llm.completion",
actionName: "openai:gpt-4o",
// dryRun: true, ← remove this line
},
async (document: string) => { ... },
);
```
```java [Spring Boot]
// Remove dryRun to enable enforcement
@Cycles(value = "2000000",
actionKind = "llm.completion", actionName = "openai:gpt-4o"
// dryRun = true ← remove this line
)
public String generateSummary(String document) { ... }
```
:::
## Tips for existing applications
### Keep your existing error handling
Don't replace your existing try/except or try/catch blocks. Add Cycles error handling alongside them:
```python
try:
result = generate_summary(document)
except BudgetExceededError:
result = "Budget limit reached."
except openai.APIError as e:
# Your existing error handling stays
result = handle_openai_error(e)
```
### Start with generous budgets
When first deploying, set budgets higher than you think you need. You can tighten them after collecting real usage data. Under-budgeting on day one creates unnecessary friction.
### Use scopes to separate environments
Use different workspace scopes for dev, staging, and production:
```python
@cycles(
estimate=2000000,
action_kind="llm.completion",
action_name="openai:gpt-4o",
workspace=os.environ.get("ENVIRONMENT", "dev"), # dev, staging, prod
)
def generate_summary(document: str) -> str:
...
```
### Don't wrap everything at once
It's better to have 3 well-instrumented call paths than 30 poorly-estimated ones. Start with the calls that matter most.
## Next steps
- [Shadow Mode Rollout](/how-to/shadow-mode-in-cycles-how-to-roll-out-budget-enforcement-without-breaking-production) — full guide to safe rollout
- [Cost Estimation Cheat Sheet](/how-to/cost-estimation-cheat-sheet) — how much to reserve per model
- [Choosing the Right Integration Pattern](/how-to/choosing-the-right-integration-pattern) — decorator vs programmatic vs middleware
- [Integrations Overview](/how-to/integrations-overview) — all supported frameworks and providers
- [Degradation Paths](/how-to/how-to-think-about-degradation-paths-in-cycles-deny-downgrade-disable-or-defer) — what to do when budget is denied
# API Key Management in Cycles
Every request to the Cycles server requires an API key. This page explains how API keys work, how to create and manage them, and how they relate to tenant isolation.
## How API keys work
The Cycles server authenticates requests using the `X-Cycles-API-Key` header. Each API key is associated with exactly one tenant.
When a request arrives:
1. The server extracts the `X-Cycles-API-Key` header
2. Validates the key exists and is active
3. Derives the effective tenant from the key
4. Verifies that `subject.tenant` in the request body matches the key's tenant
5. If any check fails, returns `401 UNAUTHORIZED` or `403 FORBIDDEN`
This ensures strict tenant isolation — an API key for tenant A cannot create reservations or query balances for tenant B.
## Key states
An API key can be in one of three states:
| State | Meaning |
|---|---|
| `ACTIVE` | Key is valid and can be used for requests |
| `REVOKED` | Key has been manually disabled |
| `EXPIRED` | Key has passed its expiration date |
Only `ACTIVE` keys are accepted. Requests with `REVOKED` or `EXPIRED` keys receive `401 UNAUTHORIZED`.
## Creating API keys
API keys are managed through the [Cycles Admin](https://github.com/runcycles/cycles-server-admin) interface:
```bash
# Create a new API key for a tenant
curl -X POST http://localhost:7979/v1/admin/api-keys \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: $ADMIN_API_KEY" \
-d '{
"tenant_id": "acme",
"name": "production-chatbot",
"description": "Production chatbot key",
"permissions": ["reservations:create", "reservations:commit", "reservations:release", "balances:read"]
}'
```
### Available permissions (27 total)
**Tenant-scoped permissions** (used with `X-Cycles-API-Key` for runtime and tenant operations):
| Permission | Grants | Default |
|---|---|---|
| `reservations:create` | Create new reservations | Yes |
| `reservations:commit` | Commit existing reservations | Yes |
| `reservations:release` | Release existing reservations | Yes |
| `reservations:extend` | Extend reservation TTL | Yes |
| `reservations:list` | List reservations | Yes |
| `balances:read` | Query balance information | Yes |
| `webhooks:write` | Create, update, delete, and test tenant webhooks at `/v1/webhooks` | No |
| `webhooks:read` | List tenant webhooks and delivery history | No |
| `events:read` | Query tenant event stream at `/v1/events` | No |
**Tenant budget/policy permissions** (v0.1.25.6+):
| Permission | Grants | Default? |
|---|---|---|
| `budgets:read` | List and read own tenant budgets | Yes |
| `budgets:write` | Create and fund own tenant budgets | Yes |
| `policies:read` | List and read own tenant policies | Yes |
| `policies:write` | Create and update own tenant policies | Yes |
**Admin wildcard permissions** (used with `X-Cycles-API-Key` for admin operations):
| Permission | Grants |
|---|---|
| `admin:read` | Satisfies **any** `*:read` permission (budgets:read, policies:read, webhooks:read, events:read, etc.) |
| `admin:write` | Satisfies **any** `*:write` permission (budgets:write, policies:write, webhooks:write, etc.). Does NOT grant read access — use both `admin:read` and `admin:write` for full access. |
::: tip Wildcard behavior (v0.1.25.7+)
`admin:write` acts as a server-level wildcard — it satisfies any `*:write` permission requirement. This means pre-v0.1.25.6 keys with `admin:write` continue to work without migration even after granular permissions were introduced. `admin:read` does NOT satisfy `*:write`.
:::
**Admin granular permissions** (v0.1.25+ — finer-grained alternative to admin wildcards):
| Permission | Grants |
|---|---|
| `admin:tenants:read` | Read tenant details |
| `admin:tenants:write` | Create and update tenants |
| `admin:budgets:read` | List and read budgets |
| `admin:budgets:write` | Create, fund, and update budgets |
| `admin:policies:read` | List and read policies |
| `admin:policies:write` | Create and update policies |
| `admin:apikeys:read` | List API keys |
| `admin:apikeys:write` | Create and revoke API keys |
| `admin:webhooks:read` | List admin webhook subscriptions and deliveries |
| `admin:webhooks:write` | Create, update, delete, test, and replay admin webhooks |
| `admin:events:read` | Query admin event stream |
| `admin:audit:read` | Query audit logs |
> **Defaults:** When no permissions are specified at key creation, the key receives 10 default permissions: the 6 runtime permissions (`reservations:create`, `reservations:commit`, `reservations:release`, `reservations:extend`, `reservations:list`, `balances:read`) plus `budgets:read`, `budgets:write`, `policies:read`, `policies:write`. Webhook, event, and admin permissions must be explicitly requested.
A typical runtime key needs only the 6 runtime permissions (`reservations:*` and `balances:read`) — remember that an explicit `permissions` array replaces the 10-value default set, so include `budgets:read`/`budgets:write` if the key also manages budgets. Add `webhooks:write` and `webhooks:read` for [webhook subscriptions](/how-to/managing-webhooks#tenant-self-service). Cross-tenant admin operations require the admin server's `X-Admin-API-Key` — no tenant-key permission (including the legacy `admin:read`/`admin:write` wildcards) grants access to those endpoints.
::: warning Admin permissions on tenant keys (v0.1.25.7)
`admin:read` and `admin:write` are accepted on tenant keys for backward compatibility, but **SHOULD NOT be assigned to new tenant keys**. Use the specific permissions (`budgets:write`, `policies:read`, etc.) instead. The admin key (`X-Admin-API-Key`) is server-configured and is not provisioned through the API key creation endpoint.
No permission on a tenant key — including `admin:read`/`admin:write` and the granular `admin:*` permissions — ever grants access to **AdminKeyAuth-only** endpoints (tenant management, API key management, audit, admin webhooks/events/config/overview). Those endpoints are unreachable with a tenant key; they accept only the server-configured `X-Admin-API-Key` header.
:::
For the full endpoint-to-header-to-permission mapping, see the [Architecture Overview — Authentication](/quickstart/architecture-overview-how-cycles-fits-together#authentication).
Response:
```json
{
"key_id": "key_abc123...",
"key_secret": "cyc_live_abc123...",
"key_prefix": "cyc_live_abc12",
"tenant_id": "acme",
"permissions": ["reservations:create", "reservations:commit", "reservations:release", "balances:read"],
"created_at": "2026-03-01T00:00:00Z",
"expires_at": "2026-05-30T00:00:00Z"
}
```
Store the API key securely. It is shown only once at creation time.
## Using API keys
### In the Python client
Configure the key via `CyclesConfig`:
```python
import os
from runcycles import CyclesConfig
config = CyclesConfig(
base_url="http://localhost:7878",
api_key=os.environ["CYCLES_API_KEY"],
tenant="acme",
)
```
Or from environment variables:
```bash
export CYCLES_BASE_URL=http://localhost:7878
export CYCLES_API_KEY=cyc_live_abc123...
export CYCLES_TENANT=acme
```
```python
config = CyclesConfig.from_env()
```
### In the Spring Boot Starter
Configure the key in your project's `application.yml`:
```yaml
cycles:
api-key: ${CYCLES_API_KEY}
base-url: http://localhost:7878
tenant: acme
```
Use an environment variable rather than hardcoding the key.
### In direct HTTP calls
Pass the key in the `X-Cycles-API-Key` header:
```bash
curl -X POST http://localhost:7878/v1/reservations \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: cyc_live_abc123..." \
-d '{ ... }'
```
## Listing API keys
`GET /v1/admin/api-keys` lists keys. The endpoint is **AdminKeyAuth-only** — it requires the `X-Admin-API-Key` header, and tenant keys (`X-Cycles-API-Key`) cannot call it regardless of the permissions they carry. `tenant_id` is an optional filter: omit it to list keys across all tenants (v0.1.25.22+), or provide it to scope the result to a single tenant. Cross-tenant results are cursor-paginated; the cursor format is an implementation detail — treat it as an opaque string and pass it back unchanged.
```bash
# Cross-tenant — all keys, newest first
curl -G "http://localhost:7979/v1/admin/api-keys" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
--data-urlencode "sort_by=created_at" \
--data-urlencode "sort_dir=desc" \
--data-urlencode "limit=50" | jq .
# Tenant-scoped
curl -G "http://localhost:7979/v1/admin/api-keys?tenant_id=acme" \
-H "X-Admin-API-Key: $ADMIN_KEY" | jq .
```
`search` (v0.1.25.25+) does a case-insensitive match over `key_id` and `name`. See [Searching and Sorting Admin List Endpoints](/how-to/searching-and-sorting-admin-list-endpoints) for the full parameter vocabulary.
## Revoking API keys
::: tip Revoke from the dashboard
Key revocation is also a one-click action on the API Keys page in the [Cycles Admin Dashboard](/quickstart/deploying-the-cycles-dashboard) — typically faster than crafting a curl when responding to a leaked-key incident. Revocation is irreversible; the dashboard surfaces a confirmation step.
:::
Revoke a key to immediately block all requests using it:
```bash
curl -X DELETE "http://localhost:7979/v1/admin/api-keys/key_abc123?reason=leaked+in+ci+logs" \
-H "X-Admin-API-Key: $ADMIN_API_KEY"
```
The optional `reason` query parameter is recorded for the audit trail. Revoking a key that is already revoked returns `409` (`ALREADY_REVOKED`).
Revocation is immediate. Any in-flight requests using the revoked key will fail on their next call to the Cycles server. Active reservations created with the revoked key remain valid until they expire or are committed/released.
::: tip Revocation, not deletion
The `DELETE` endpoint performs a **status transition** (ACTIVE → REVOKED), not a hard delete. The key record is retained so that audit logs referencing the key remain resolvable. This is consistent with the lifecycle model used across Cycles — see the equivalent notes on [tenant closure](/how-to/tenant-creation-and-management-in-cycles#closed) and [budget decommissioning](/how-to/budget-allocation-and-management-in-cycles#resizing-a-budget-reset).
:::
## Updating a key without rotating
`PATCH /v1/admin/api-keys/{key_id}` (AdminKeyAuth-only) performs a partial update — only the fields you send are modified. Mutable fields: `permissions` (replaces the full set), `scope_filter`, and `name`/`description`/`metadata`. `tenant_id`, `key_id`, `key_prefix`, `expires_at`, and `status` are immutable — to change expiry or tenant, revoke and recreate. Patching a revoked or expired key returns `409`.
## Key rotation
To rotate an API key without downtime:
1. Create a new key for the same tenant
2. Update your application configuration to use the new key
3. Deploy the configuration change
4. Verify traffic is flowing with the new key
5. Revoke the old key
Because both keys are valid during the transition, there is no interruption.
## Tenant isolation
API keys are the primary mechanism for tenant isolation in Cycles.
**Enforced behaviors:**
- A key for tenant A can only create reservations where `subject.tenant = "A"` (or where tenant is omitted, in which case the server uses the key's tenant)
- A key for tenant A cannot commit, release, or extend reservations owned by tenant B
- A key for tenant A cannot query balances for tenant B
- A key for tenant A cannot list reservations belonging to tenant B
**If the tenant is omitted from the Subject**, the server automatically sets it to the key's associated tenant. This is the recommended approach — configure the tenant at the key level and let the server enforce it.
## Best practices
### One key per environment
Use separate API keys for production, staging, and development, even within the same tenant. This makes it easy to revoke a single environment's access without affecting others.
### Use environment variables
Never hardcode API keys in source code:
```yaml
# Good
cycles:
api-key: ${CYCLES_API_KEY}
# Bad
cycles:
api-key: cyc_live_abc123...
```
### Minimal key scope
If you operate multiple tenants, issue one key per tenant. Do not share keys across tenants.
### Monitor key usage
Track which keys are making requests. If a key is compromised, revoke it immediately and issue a replacement.
## Error responses
| Error | HTTP | When |
|---|---|---|
| `UNAUTHORIZED` | 401 | Missing `X-Cycles-API-Key` header, or key is invalid/revoked/expired |
| `FORBIDDEN` | 403 | Key is valid but `subject.tenant` does not match the key's tenant |
| `INSUFFICIENT_PERMISSIONS` | 403 | Key is valid but lacks the required permission for the endpoint (e.g., calling `POST /v1/admin/budgets/fund` without `budgets:write`) |
## Next steps
- [Tenant Creation and Management](/how-to/tenant-creation-and-management-in-cycles) — create and manage the tenants that API keys belong to
- [Authentication and Tenancy](/protocol/authentication-tenancy-and-api-keys-in-cycles) — deeper dive into the auth model
- [Self-Hosting the Cycles Server](/quickstart/self-hosting-the-cycles-server) — deploy your own instance
- [Architecture Overview](/quickstart/architecture-overview-how-cycles-fits-together) — how authentication fits into the system
# How to Assign RISK_POINTS to AI Agent Tools
This guide walks you through an illustrative method for assigning [RISK_POINTS](/glossary#risk-points) to tool attempts. Cycles treats those caller-assigned points as a budget unit; the host still authenticates the principal, authorizes the tool and arguments, and makes the reservation boundary mandatory.
For the full risk assessment framework and regulatory context, see [AI Agent Risk Assessment](/blog/ai-agent-risk-assessment-score-classify-enforce-tool-risk). This page is the implementation-focused quick reference.
**Prerequisites:**
- You know what RISK_POINTS are ([protocol reference](/protocol/understanding-units-in-cycles-usd-microcents-tokens-credits-and-risk-points))
- You have a list of tools your agent can call
## Step 1: List your tools
Start by inventorying every tool the agent can invoke. For each tool, note:
- **Name** — the function or tool name your agent calls
- **What it does** — one-sentence description
- **What it modifies** — does it read, write locally, call external APIs, mutate records, or execute irreversible actions?
Example inventory for a customer support agent:
| Tool | What it does | What it modifies |
|---|---|---|
| `search_knowledge` | Searches the knowledge base | Nothing (read-only) |
| `get_customer` | Looks up customer by ID | Nothing (read-only) |
| `save_draft_note` | Saves internal draft note | Local, reversible |
| `call_crm_api` | Reads customer data from CRM | External API (read) |
| `send_customer_email` | Sends email to customer's inbox | External, hard to reverse |
| `update_crm_status` | Updates customer status in CRM | External, hard to reverse |
| `create_jira_ticket` | Creates a Jira ticket (triggers notifications) | External, customer-visible |
| `issue_refund` | Processes a financial refund | Irreversible |
## Step 2: Classify each tool into a risk tier
Use these three questions to classify:
```
Does this tool modify any state?
├── NO → Tier 0 (Read-only)
│
└── YES
├── Can a human undo this in under 5 minutes?
│ └── YES → Tier 1 (Write-local)
│
├── Does it leave your system boundary (external API)?
│ └── YES, but reversible → Tier 2 (Write-external)
│
├── Does it affect someone who did not request it?
│ (customer, external party, end user)
│ └── YES, hard to reverse → Tier 3 (Mutation)
│
└── Is the change irreversible? (deploy, payment, permission grant)
└── YES → Tier 4 (Execution)
```
The third question — **"Does it affect someone who did not request it?"** — is the key differentiator between Tier 2 (external but contained) and Tier 3 (customer-facing impact). An API call to a third-party service you control is Tier 2. An email landing in a customer's inbox is Tier 3 — even though both are external.
This guide proposes the following **starting scores**. They are not built into the server and are not a compliance standard:
| Tier | Type | Base Points | Rationale |
|:---:|---|:---:|---|
| 0 | Read-only | 0 | No side effects — reads should be free |
| 1 | Write-local | 1 | Low impact, easily reversible |
| 2 | Write-external | 5 | External dependency, some coordination to reverse |
| 3 | Mutation | 20 | Customer-facing impact, difficult to reverse |
| 4 | Execution | 50 | Irreversible, financial or production impact |
::: tip Edge cases and rules of thumb
- **If a tool matches multiple tiers, assign the highest.** A generic API tool that can both read and write should be scored at the write tier.
- **If a tool's risk varies by parameter** (e.g., a generic `call_api` used for both reads and writes), score at the highest tier it can reach, or split it into separate tool definitions.
- **If unsure, score one tier higher and validate in shadow mode.** It's cheaper to loosen a tight score than to recover from an under-scored tool causing an incident.
- **When multiplied scores produce decimals** (e.g., 5 × 1.5 = 7.5), round up to the next integer.
- The same tool in different deployments can have different tiers based on what it connects to.
:::
## Step 3: Apply risk scoring multipliers
Base points assume a generic context. Four factors adjust them for your specific deployment. **Take the maximum** of all four — they don't stack multiplicatively.
**Formula:** `Final RISK_POINTS = Base × max(Audience, Sensitivity, Regulatory, Reputational)`
### Audience size
| Audience | Multiplier |
|---|:---:|
| Internal only | 1x |
| Single external party | 1.5x |
| Customer segment (<100) | 2x |
| Broad external (100+) | 3x |
### Data sensitivity
| Sensitivity | Multiplier |
|---|:---:|
| Public data | 1x |
| Internal business data | 1.5x |
| Customer PII | 2x |
| Financial / health / regulated | 3x |
### Regulatory context
| Regulatory | Multiplier |
|---|:---:|
| No specific regulation | 1x |
| General data protection (e.g., GDPR) | 1.5x |
| Industry-specific (e.g., HIPAA, PCI-DSS, SOX) | 2x |
| Multiple overlapping regulations | 3x |
### Reputational exposure
| Exposure | Multiplier |
|---|:---:|
| Internal only | 1x |
| Single external party | 1.5x |
| Customer-facing (visible to end users) | 2x |
| Press / social media / regulatory scrutiny | 3x |
### Applying the formula
For `send_customer_email`:
- Base: Tier 3 = 20 points
- Audience: customer segment (2x)
- Sensitivity: internal business data (1.5x)
- Regulatory: none (1x)
- Reputational: customer-facing (2x)
- Max multiplier: **2x** (audience or reputational)
- Final: 20 × 2 = **40 RISK_POINTS**
## Step 4: Set your per-run budget
Sum the expected tool calls for representative runs, then choose a buffer from the variation you are prepared to allow:
1. Count how many times each tool is called in a normal agent run
2. Multiply each by its RISK_POINTS score
3. Sum the results
4. Choose a buffer from observed percentiles and the maximum tolerated exposure
**Example: Customer support agent**
| Tool | Score | Typical calls per run | Points consumed |
|---|:---:|:---:|:---:|
| `search_knowledge` | 0 | 4-6 | 0 |
| `get_customer` | 0 | 1-2 | 0 |
| `save_draft_note` | 1 | 1 | 1 |
| `call_crm_api` | 5 | 1 | 5 |
| `send_customer_email` | 40 | 1 | 40 |
| `update_crm_status` | 40 | 1 | 40 |
| `create_jira_ticket` | 20 | 0-1 | 0-20 |
| `issue_refund` | 150 | 0 (rare) | 0 |
| **Normal run total** | | | **86-106** |
Set per-run budget to **250 RISK_POINTS**:
- Normal resolution: ~86-106 points (comfortable headroom)
- Complex resolution (email + ticket + CRM update): ~106 points
- Single refund + email: 150 + 40 = 190 points (fits)
- Two refunds: 300 points (does **not** fit — requires escalation)
At a mandatory boundary, this illustrative budget would reject an attempt whose submitted points no longer fit. It does not itself determine whether a refund or email is authorized.
To enforce this per run, create the ledger at a unique workflow scope such as `workflow:run-12345` and send that run ID as `subjects.workflow` on every protected tool attempt. `run` is not a native scope, and `dimensions.run_id` is attribution only.
## Step 5: Validate with shadow mode
Before enforcing, run with [`dry_run: true`](/protocol/dry-run-shadow-mode-evaluation-in-cycles) long enough to cover representative traffic:
1. Create the RISK_POINTS budget via admin API
2. Set reservations to dry-run mode
3. Have the application log every dry-run result and actual outcome, then review the hypothetical denial rate by workflow and action class. The current server emits `reservation.denied` for denied evaluations, but that is not a complete shadow dataset.
- A high rate can indicate scores or budgets are too tight, incomplete scoping, or genuinely abnormal traffic.
- A low rate does not prove that the policy is effective; exercise known denial cases separately.
4. Adjust individual tool scores or the run budget based on the data
5. When denial patterns are no longer surprising, enable enforcement
See the [shadow mode rollout guide](/how-to/shadow-mode-in-cycles-how-to-roll-out-budget-enforcement-without-breaking-production) for the full process.
## Common AI agent tool classifications
Reference table for 25+ common tools. **These are starting points** — adjust based on your deployment context using the multiplier framework above.
### Tier 0 — Read-only (0 base points)
| Tool | Description | Typical final score |
|---|---|:---:|
| `search` / `search_knowledge` | Search knowledge base, vector DB | 0 |
| `get_user` / `get_customer` | Look up user/customer record | 0 |
| `read_file` | Read a local file | 0 |
| `get_weather` | Weather API lookup | 0 |
| `web_search` | Web search (Google, Bing) | 0 |
| `list_records` | List/query database records | 0 |
| `get_balance` | Check account balance | 0 |
::: tip Reads in regulated environments
If your agent handles PII, financial, or health data, consider assigning 1-2 points to sensitive read tools or tracking them under a separate budget. An agent that reads 10,000 customer records hasn't changed state, but has created a data-exposure surface.
:::
### Tier 1 — Write-local (1 base point)
| Tool | Description | Typical final score |
|---|---|:---:|
| `save_draft` | Save draft document | 1 |
| `log_event` | Write to application log | 1 |
| `update_cache` | Update local cache | 1 |
| `save_note` / `add_internal_note` | Save internal note | 1-2 |
### Tier 2 — Write-external (5 base points)
| Tool | Description | Typical final score |
|---|---|:---:|
| `call_external_api` | Generic third-party API call | 5-10 |
| `webhook_post` | Fire a webhook | 5-10 |
| `post_to_internal_slack` | Post to an internal Slack channel | 5 |
| `generate_image` | Generate image via API (Stable Diffusion, DALL-E) | 5-10 |
| `generate_video` | Generate video via API | 10-15 |
### Tier 3 — Mutation (20 base points)
| Tool | Description | Typical final score |
|---|---|:---:|
| `send_email` / `send_customer_email` | Send email to customer | 40-60 |
| `create_jira_ticket` / `create_support_ticket` | Create ticket (triggers customer-visible notifications) | 20-40 |
| `send_external_slack` | Post to customer-shared Slack channel | 40-60 |
| `update_record` / `update_crm` | Update database/CRM record | 20-40 |
| `delete_record` | Delete a database record | 40-60 |
| `create_calendar_event` | Schedule meeting on someone's calendar | 20-40 |
| `update_subscription` | Modify a customer's subscription | 40-60 |
### Tier 4 — Execution (50 base points)
| Tool | Description | Typical final score |
|---|---|:---:|
| `deploy` / `trigger_deploy` | Deploy to production | 100-150 |
| `execute_payment` / `issue_refund` | Process financial transaction | 100-150 |
| `grant_permission` | Modify access control | 100-150 |
| `execute_code` | Run arbitrary code | 100 |
| `delete_database` | Drop a table or database | 150 |
| `modify_infrastructure` | Change cloud infrastructure (scaling, networking) | 100-150 |
## Worked example: Risk scoring an AI agent from scratch
A data analysis agent with these tools:
1. **`query_database`** — reads data → Tier 0, 0 points
2. **`web_search`** — external search → Tier 0, 0 points (read-only)
3. **`generate_chart`** — calls chart API → Tier 2, 5 points (external, reversible)
4. **`send_report_email`** — emails stakeholders → Tier 3, 20 × 2x (customer segment audience) = **40 points**
5. **`update_dashboard`** — writes to internal dashboard → Tier 1, 1 point (easily reversible)
6. **`export_to_s3`** — writes file to S3 bucket → Tier 2, 5 × 1.5x (internal business data) ≈ **8 points** (round up)
Typical run: 10 queries (0) + 3 searches (0) + 2 charts (10) + 1 email (40) + 1 dashboard update (1) + 1 export (8) = **59 points**
Set run budget: **100 RISK_POINTS** — at a mandatory boundary, the third 40-point report attempt would not fit after two such attempts, assuming no other point consumption.
## When to recalibrate risk scores
Review your scores when:
- **A new tool is added.** Classify and score it before deployment.
- **An incident occurs.** If a tool caused damage, re-evaluate its tier and multiplier.
- **Usage patterns shift.** If shadow mode shows agents consistently near the budget ceiling on normal runs, the budget is too tight — raise it or optimize the workflow.
- **The consequence model changes.** Revisit scores when a tool gains new destinations, broader permissions, more sensitive data, or a larger audience.
## Next steps
- [Budget Allocation and Management](/how-to/budget-allocation-and-management-in-cycles) — configure your scored tool list and budget via the admin API
- [Common Budget Patterns](/how-to/common-budget-patterns) — per-tenant, per-workflow, per-run budget structures
- [Shadow Mode Rollout](/how-to/shadow-mode-in-cycles-how-to-roll-out-budget-enforcement-without-breaking-production) — validating scores before enforcing
- [Degradation Paths](/how-to/how-to-think-about-degradation-paths-in-cycles-deny-downgrade-disable-or-defer) — what agents should do when risk budget runs low
- [AI Agent Risk Assessment](/blog/ai-agent-risk-assessment-score-classify-enforce-tool-risk) — the full framework deep-dive with regulatory context
- [Event Payloads Reference](/protocol/event-payloads-reference) — monitoring commit overage and denial events
# Budget Allocation and Management in Cycles
Before you can enforce budgets with Cycles, budgets need to be allocated to scopes. This page explains how budget allocation works, how to set it up, and how to manage budgets over time.
## What is allocation?
Allocation is the total budget assigned to a scope. It is the ceiling against which reservations and commits are measured.
The formula for remaining budget is:
```
remaining = allocated - spent - reserved - debt
```
A reservation succeeds only if `remaining >= estimate` across all affected scopes.
## How allocation works
Each scope in Cycles has an `allocated` value. When a client creates a reservation, the server checks the allocated budget for every scope in the derived hierarchy.
For example, if a reservation targets:
```json
{
"tenant": "acme",
"workspace": "production",
"app": "chatbot"
}
```
Three scopes are checked:
- `tenant:acme` — must have sufficient remaining budget
- `tenant:acme/workspace:production` — must have sufficient remaining budget
- `tenant:acme/workspace:production/app:chatbot` — must have sufficient remaining budget
All three must pass for the reservation to succeed.
## Setting budgets
Budget allocation is managed through the [Cycles Admin Server](https://github.com/runcycles/cycles-server-admin) API (port 7979 by default). The admin server and the runtime Cycles server share the same Redis instance.
### Authentication
Budget, policy, and balance endpoints on the admin server require a tenant-scoped API key (`X-Cycles-API-Key`) with the appropriate permissions:
- **`budgets:write`** — required for creating budgets, funding, and resetting (or `admin:write` as wildcard)
- **`budgets:read`** — required for listing and querying budgets (or `admin:read` as wildcard)
- **`policies:write`** — required for creating and updating policies (or `admin:write` as wildcard)
- **`policies:read`** — required for listing and querying policies (or `admin:read` as wildcard)
Default API keys (created without explicit permissions) include `budgets:write` and `budgets:read` as of v0.1.25.6 and will work for budget operations. Keys created before v0.1.25.6 with explicitly specified permission sets may need `budgets:write` and/or `budgets:read` added. See [API Key Management](/how-to/api-key-management-in-cycles#available-permissions) for the full permission list.
::: warning X-Admin-API-Key vs X-Cycles-API-Key
The bootstrap admin key (`X-Admin-API-Key`) is used for tenant management, API key management, audit log access, and budget PATCH/freeze/unfreeze (admin-only operations). Budget **create**, **list**, and **fund** are dual-auth — they accept either `X-Cycles-API-Key` (with `budgets:write`/`budgets:read`) or `X-Admin-API-Key`. Under the admin key: create requires `tenant_id` in the request **body** (and it must be omitted under a tenant key — the tenant is implicit); fund requires the `tenant_id` **query parameter**; list treats `tenant_id` as an optional filter (omit it for a cross-tenant listing). Admin-key writes are audit-logged with `actor_type=admin_on_behalf_of` (the lowercase wire value; spec prose sometimes shows the enum constant `ADMIN_ON_BEHALF_OF`).
:::
### Using the Cycles Admin API
Create budget ledgers and fund them via the admin API:
```bash
# Create a tenant budget ledger
curl -X POST http://localhost:7979/v1/admin/budgets \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"scope": "tenant:acme",
"unit": "USD_MICROCENTS",
"allocated": { "amount": 1000000, "unit": "USD_MICROCENTS" }
}'
# Fund the budget
curl -X POST "http://localhost:7979/v1/admin/budgets/fund?scope=tenant:acme&unit=USD_MICROCENTS" \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"operation": "CREDIT",
"amount": { "amount": 1000000, "unit": "USD_MICROCENTS" },
"idempotency_key": "fund-acme-001"
}'
# Create a workspace budget within that tenant
curl -X POST http://localhost:7979/v1/admin/budgets \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"scope": "tenant:acme/workspace:production",
"unit": "USD_MICROCENTS",
"allocated": { "amount": 500000, "unit": "USD_MICROCENTS" }
}'
curl -X POST "http://localhost:7979/v1/admin/budgets/fund?scope=tenant:acme/workspace:production&unit=USD_MICROCENTS" \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"operation": "CREDIT",
"amount": { "amount": 500000, "unit": "USD_MICROCENTS" },
"idempotency_key": "fund-acme-prod-001"
}'
```
::: info Note
Tenants and API keys must be created first using the admin key (`X-Admin-API-Key`). See [Deploying the Full Cycles Stack](/quickstart/deploying-the-full-cycles-stack) for the complete bootstrap sequence.
:::
### Budget hierarchy
Budgets are independent at each scope level. A tenant budget of 1,000,000 does not automatically distribute to child scopes.
You set the allocated amount at each level you want to control:
| Scope | Allocated | Purpose |
|---|---|---|
| `tenant:acme` | 1,000,000 | Global cap for the tenant |
| `tenant:acme/workspace:production` | 500,000 | Cap for the production environment |
| `tenant:acme/workspace:production/app:chatbot` | 100,000 | Cap for the chatbot app |
A reservation for 10,000 against the chatbot scope must pass all three levels.
### Unallocated scopes
If a scope has a budget ledger with zero allocation (`allocated = 0`), any reservation targeting it will be denied with `BUDGET_EXCEEDED` (409). The ledger exists but has no room.
If a scope has no budget ledger at all, it is **skipped** during enforcement — it does not block the reservation. This is different from zero allocation: a missing ledger is ignored, a zero-allocation ledger is enforced.
### How budget lookup works during reservations
When the server processes a reservation, it derives scope paths from the subject (e.g., `tenant:acme`, `tenant:acme/workspace:prod`, `tenant:acme/workspace:prod/app:chatbot`) and checks each for a budget ledger:
1. Scopes **with** a budget ledger are checked for sufficient funds
2. Scopes **without** a budget ledger are skipped — they do not block the reservation
3. If **no** derived scope has a budget ledger, the reservation is rejected with `NOT_FOUND` (404) — the response message is `"Budget not found for provided scope: ..."`. (On `/v1/decide` and dry-run reserve, the same condition surfaces as `200 DENY` with `reason_code=BUDGET_NOT_FOUND`.)
4. If **any** budgeted scope has insufficient funds, the reservation is rejected with `BUDGET_EXCEEDED` (409)
This means you only need budgets at the scope levels where you want enforcement. For example, if you only set a tenant-level budget, workspace and app scopes are skipped — the tenant budget is the only constraint.
| Scenario | Result |
|---|---|
| Budget at tenant only, reservation targets tenant/workspace/app | Reserves against tenant budget; workspace and app skipped |
| Budget at tenant and app, not workspace | Reserves against both; workspace skipped |
| No budget at any scope | `NOT_FOUND` (404) — message: `"Budget not found for provided scope: ..."` |
| Budget exists with zero allocation | `BUDGET_EXCEEDED` (409) |
## Common allocation patterns
### Flat tenant budgets
The simplest approach: allocate a single budget at the tenant level.
```
tenant:acme → allocated: 1,000,000
```
Every reservation by tenant `acme` draws from this single pool. No per-workspace or per-app limits.
### Tenant + workspace budgets
Add workspace-level budgets for environment isolation:
```
tenant:acme → allocated: 1,000,000
tenant:acme/workspace:production → allocated: 500,000
tenant:acme/workspace:staging → allocated: 200,000
tenant:acme/workspace:development → allocated: 300,000
```
Production cannot consume more than 500,000, even if the tenant has remaining budget elsewhere.
### Per-workflow run budgets
For short-lived workflows, allocate budgets per run using the workflow field:
```
tenant:acme/workspace:production/workflow:run-12345 → allocated: 50,000
```
This caps a single workflow execution at 50,000 units.
### Per-agent budgets
For multi-agent systems, allocate per agent:
```
tenant:acme/workspace:production/agent:planner → allocated: 100,000
tenant:acme/workspace:production/agent:executor → allocated: 200,000
tenant:acme/workspace:production/agent:reviewer → allocated: 50,000
```
### Custom dimensions are not budget scopes
The Subject's optional `dimensions` map (e.g., `cost_center`, `region`) does **not** participate in scope derivation. Scopes are derived only from the six standard Subject fields, in canonical order: `tenant` → `workspace` → `app` → `workflow` → `agent` → `toolset`. There is no `dimensions:` scope segment, so you cannot allocate a budget to a dimension value. v0 servers may ignore `dimensions` for budgeting decisions entirely (they only have to accept and round-trip it) — use dimensions for reporting and policy taxonomies, and model any dimension you need to *enforce* as one of the six standard fields instead.
## Updating budget configuration
Use `PATCH /v1/admin/budgets?scope={scope}&unit={unit}` to update mutable budget properties without re-creating the ledger:
```bash
curl -s -X PATCH "http://localhost:7979/v1/admin/budgets?scope=tenant:acme&unit=USD_MICROCENTS" \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-d '{
"overdraft_limit": { "amount": 500000, "unit": "USD_MICROCENTS" },
"commit_overage_policy": "ALLOW_WITH_OVERDRAFT",
"metadata": { "cost_center": "engineering" }
}' | jq .
```
You can update:
- **`overdraft_limit`** — maximum allowed debt. When changed, `is_over_limit` is atomically recalculated.
- **`commit_overage_policy`** — per-ledger overage policy override (`REJECT`, `ALLOW_IF_AVAILABLE`, `ALLOW_WITH_OVERDRAFT`).
- **`metadata`** — key-value pairs for external references (replaces the full metadata object).
Fields not included in the request are left unchanged. Returns `404` if the budget does not exist, `403` for tenant mismatch, and `409` if the budget is `CLOSED`.
## Freezing and unfreezing budgets
*New in v0.1.25.6.*
Use freeze to immediately halt all new reservations against a budget without deleting or modifying it. This is useful during incident investigations, compliance holds, or when a runaway agent is detected.
::: tip Freeze from the dashboard
Freeze and unfreeze are also one-click actions on the Budgets page in the [Cycles Admin Dashboard](/quickstart/deploying-the-cycles-dashboard) — typically faster during an active incident than crafting a curl. The dashboard also exposes an **Emergency Freeze (tenant-wide)** action that sequentially freezes every ACTIVE budget for a tenant with a confirm + blast-radius summary.
:::
### Freeze
```bash
curl -s -X POST "http://localhost:7979/v1/admin/budgets/freeze?scope=tenant:acme&unit=USD_MICROCENTS" \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-d '{"reason": "Investigating runaway agent in support workflow"}' | jq .
```
All new live reservations (and events) against the frozen scope fail with `409` and `error: BUDGET_FROZEN`. Only `/v1/decide` and dry-run reserve surface the condition as a `200` response with `decision: DENY` and `reason_code: BUDGET_FROZEN`. Fund operations also return `409` while frozen. The freeze gate applies to *new* reservations — the spec does not block committing or releasing reservations that were already active when the freeze landed. Emits a `budget.frozen` webhook event.
### Unfreeze
```bash
curl -s -X POST "http://localhost:7979/v1/admin/budgets/unfreeze?scope=tenant:acme&unit=USD_MICROCENTS" \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-d '{"reason": "Investigation complete — root cause was prompt loop, now fixed"}' | jq .
```
Transitions FROZEN → ACTIVE. Reservations resume immediately. Emits a `budget.unfrozen` webhook event. Returns 409 if the budget is already active or closed.
::: tip When to freeze vs. adjust budget
**Freeze** when you need to stop all activity immediately while investigating. The budget allocation and history are preserved. **Adjust the budget** (PATCH or fund) when you want to change how much is available. Freeze is an operational control; budget adjustment is a financial control.
:::
## Adjusting budget allocation
### Increasing a budget
Increase the `allocated` value to give a scope more room. This takes effect immediately — the next reservation check will use the new value.
### Decreasing a budget
Decrease the `allocated` value. If the new value is less than `spent + reserved`, existing reservations are not affected, but new reservations may be denied.
### Resizing a budget (RESET)
To **change the allocated ceiling** while preserving consumption history (`spent`, `reserved`, `debt`), use `RESET`:
```bash
curl -X POST "http://localhost:7979/v1/admin/budgets/fund?scope=tenant:acme&unit=USD_MICROCENTS" \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"operation": "RESET",
"amount": { "amount": 1500000, "unit": "USD_MICROCENTS" },
"idempotency_key": "resize-acme-q2",
"reason": "Plan upgrade — Pro tier"
}'
```
`RESET` sets `allocated = amount` and recalculates `remaining = amount - reserved - spent - debt`. Spent stays where it was. Use this for **plan changes, policy tightening, ceiling adjustments** — the typical "this customer moved to a bigger plan" or "we're tightening this team's limit" scenarios.
Release active reservations first if you're shrinking the ceiling below `spent + reserved` and want a clean cutover.
::: warning RESET is for resizing, not period boundaries
For a fresh billing period (clearing consumption), use `RESET_SPENT` below. A same-amount `RESET` on an exhausted budget is a no-op — `spent` stays at its old value, so `remaining` stays at 0.
:::
### Starting a new billing period (RESET_SPENT)
To **start a new billing period** — clearing accumulated spend so the scope can transact fresh — use `RESET_SPENT`:
```bash
curl -X POST "http://localhost:7979/v1/admin/budgets/fund?scope=tenant:acme&unit=USD_MICROCENTS" \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"operation": "RESET_SPENT",
"amount": { "amount": 1000000, "unit": "USD_MICROCENTS" },
"idempotency_key": "reset-march-2026",
"reason": "Monthly billing period reset — March 2026"
}'
```
`RESET_SPENT` sets `allocated = amount`, **clears spent to 0**, and preserves `reserved` (active reservations straddle the period boundary and will land in the new period's spent when they commit) and `debt` (period boundaries don't forgive debt — use `REPAY_DEBT` to clear it explicitly).
#### Optional `spent` override
For migrations, prorated signups, and corrections, supply an explicit `spent`:
```bash
# Migration: import an existing customer with their consumption already reflected
curl -X POST "http://localhost:7979/v1/admin/budgets/fund?scope=tenant:acme&unit=USD_MICROCENTS" \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"operation": "RESET_SPENT",
"amount": { "amount": 1000000, "unit": "USD_MICROCENTS" },
"spent": { "amount": 400000, "unit": "USD_MICROCENTS" },
"idempotency_key": "migrate-acme-from-billing-vendor",
"reason": "Imported from billing vendor — current period 40% consumed"
}'
```
The `spent` field is honoured **only** for `RESET_SPENT`. Common patterns:
| Scenario | `spent` value | Notes |
|---|---|---|
| Routine billing-period rollover | omit (defaults to 0) | The 90% case. |
| Migration from another billing system | actual current consumption | Customer arrives with history; reflect it. |
| Prorated mid-period signup | `allocated × (days_remaining / period_days)` | New customer joins partway through. |
| Credit-back / compensation | reduced consumption value | Refund a portion after a service incident. |
| State correction | corrected value | Fix a miscounted `spent` from an upstream bug. |
Constraints:
- `spent` must be `>= 0`.
- The unit must match the budget's unit.
- The audit log records whether `spent` was explicitly supplied or defaulted to 0, distinguishing routine rollovers from operator-initiated consumption adjustments for compliance review.
##### What `remaining` looks like after RESET_SPENT
In the common case — no outstanding `debt`, no active `reserved`, `spent` omitted — `RESET_SPENT(amount=X)` produces `allocated = X` and `remaining = X`. A clean fresh period.
`remaining` can start the new period **negative** in two specific situations:
- **Carryover.** Preserved `debt` (and/or active `reserved`) exceed the new `allocated`. Periods don't forgive debt by design — use `REPAY_DEBT` if you want to clear it. Example: old period ended with `debt=1200`; `RESET_SPENT(amount=1000)` yields `remaining = 1000 - 0 - 0 - 1200 = -200`.
- **Explicit override.** You pass `spent` larger than `allocated - reserved - debt`. Example: migrating a customer already partway through a period with `RESET_SPENT(amount=1000, spent=1200)` yields `remaining = -200`.
Both cases are valid ledger states, not errors. The response returns the negative value, and the invariant `remaining = allocated - spent - reserved - debt` holds.
##### Recovery pattern: truly starting fresh when the prior period ended in debt
`RESET_SPENT` preserves `debt` by design — periods don't silently forgive obligations. If you want a customer to start the new period with a clean slate (no carryover debt, full ceiling available), pair `REPAY_DEBT` with `RESET_SPENT`:
```bash
# Prior period ended with: allocated=1000, spent=1000, debt=200, remaining=0
# Step 1: Clear the outstanding debt (e.g., after the customer paid their invoice).
curl -X POST "https://admin.example.com/v1/admin/budgets/fund?tenant_id=acme&scope=tenant:acme&unit=USD_MICROCENTS" \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-d '{
"operation": "REPAY_DEBT",
"amount": { "amount": 200, "unit": "USD_MICROCENTS" },
"idempotency_key": "repay-acme-before-rollover",
"reason": "invoice paid"
}'
# State now: allocated=1000, spent=1000, debt=0, remaining=0
# Step 2: Start the new billing period with a fresh ceiling.
curl -X POST "https://admin.example.com/v1/admin/budgets/fund?tenant_id=acme&scope=tenant:acme&unit=USD_MICROCENTS" \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-d '{
"operation": "RESET_SPENT",
"amount": { "amount": 1000, "unit": "USD_MICROCENTS" },
"idempotency_key": "rollover-acme-after-repay",
"reason": "monthly rollover"
}'
# State now: allocated=1000, spent=0, debt=0, remaining=1000
```
Order matters: if you skip step 1, the carryover `debt` will make the new period's `remaining` start negative. That's the correct behaviour for "customer still owes from last period", but it's not what you want if the debt has already been settled externally. Run `REPAY_DEBT` first whenever you want the next period to begin at the full ceiling.
#### Event emission
`RESET_SPENT` emits `budget.reset_spent` (distinct from `budget.reset`) so dashboards and webhook handlers can route period boundaries separately from resize events. The payload's `spent_override_provided` boolean flags which mode was used.
::: info Why budgets cannot be deleted
The admin API has no delete endpoint for budgets. A budget ledger is the permanent audit record for all spend within a scope — committed reservations reference it, and historical balances are derived from it. Deleting a ledger would create orphaned transactions and break spend reporting.
To decommission a budget: `RESET` its allocation to zero (or `DEBIT` the remaining balance). No new reservations will be approved against a zero-balance scope. The ledger stays in the system for historical queries but has no operational cost.
:::
### Funding after overdraft
If a scope has accumulated debt through `ALLOW_WITH_OVERDRAFT` commits, repay it:
```bash
curl -X POST "http://localhost:7979/v1/admin/budgets/fund?scope=tenant:acme&unit=USD_MICROCENTS" \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"operation": "REPAY_DEBT",
"amount": { "amount": 500000, "unit": "USD_MICROCENTS" },
"idempotency_key": "repay-001"
}'
```
While `debt > 0` and no `overdraft_limit` is configured, new reservations against that scope are blocked with `DEBT_OUTSTANDING`. When an `overdraft_limit > 0` is set, debt within the limit does not block new reservations.
## Monitoring budgets
Use the `GET /v1/balances` endpoint to check budget state:
```bash
curl -s "http://localhost:7878/v1/balances?tenant=acme" \
-H "X-Cycles-API-Key: your-api-key"
```
This returns the current state for all scopes matching the filter:
```json
{
"balances": [
{
"scope": "tenant:acme",
"scope_path": "tenant:acme",
"remaining": { "amount": 750000, "unit": "USD_MICROCENTS" },
"allocated": { "amount": 1000000, "unit": "USD_MICROCENTS" },
"spent": { "amount": 200000, "unit": "USD_MICROCENTS" },
"reserved": { "amount": 50000, "unit": "USD_MICROCENTS" },
"debt": { "amount": 0, "unit": "USD_MICROCENTS" },
"overdraft_limit": { "amount": 0, "unit": "USD_MICROCENTS" },
"is_over_limit": false
}
]
}
```
Key fields to monitor:
- **remaining** — how much room is left
- **reserved** — how much is currently held by active reservations
- **debt** — any overdraft accumulation
- **is_over_limit** — whether the scope is blocked
## Managing policies
Policies define stored caps, rate limits, and behavioral overrides matched by scope pattern.
::: warning v0 limitation
In v0, the protocol server (port 7878) does not evaluate admin-defined policies when processing reservations, commits, or events. Enforcement is planned for a future version. Today, the only policy-like behavior enforced at runtime is the `overage_policy` resolved from the request or the tenant's `default_commit_overage_policy`; budget ledgers and tenant defaults supply the rest of runtime governance. Use admin policies to model intended governance state and prepare for future enforcement.
:::
Create a policy record for matching scopes:
```bash
curl -s -X POST http://localhost:7979/v1/admin/policies \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"name": "production-limits",
"scope_pattern": "tenant:acme-corp/workspace:production/*",
"priority": 10,
"commit_overage_policy": "REJECT",
"rate_limits": {
"max_reservations_per_minute": 100,
"max_commits_per_minute": 100
},
"caps": {
"max_tokens": 4096
}
}' | jq .
```
### Updating a policy
Use `PATCH /v1/admin/policies/{policy_id}` to modify mutable fields without re-creating the policy:
```bash
curl -s -X PATCH "http://localhost:7979/v1/admin/policies/$POLICY_ID" \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"priority": 20,
"caps": { "max_tokens": 8192 },
"rate_limits": {
"max_reservations_per_minute": 200,
"max_commits_per_minute": 200
}
}' | jq .
```
You can update: `name`, `description`, `priority`, `caps`, `commit_overage_policy`, `reservation_ttl_override`, `rate_limits`, `effective_from`, `effective_until`, and `status`. Fields not included in the request are left unchanged. Set `status` to `DISABLED` to deactivate a policy without deleting it.
## Summary
Budget allocation in Cycles:
- Is set per scope independently
- Is enforced atomically across the full scope hierarchy for each reservation
- Can be adjusted at any time with immediate effect
- Requires explicit allocation at every scope level you want to control
- Supports flat, hierarchical, per-run, and per-agent patterns (scopes derive from the six standard Subject fields; `dimensions` never derive scopes — they serve reporting, taxonomies, and policy/quota uses)
## Next steps
- [Tenants, Scopes, and Budgets](/how-to/understanding-tenants-scopes-and-budgets-in-cycles) — how tenants, scopes, and budgets work together as a unified model
- [Tenant Creation and Management](/how-to/tenant-creation-and-management-in-cycles) — create and configure tenants before allocating budgets
- [Querying Balances](/protocol/querying-balances-in-cycles-understanding-budget-state) — detailed balance query guide
- [Debt and Overdraft](/protocol/debt-overdraft-and-the-over-limit-model-in-cycles) — how overdraft affects allocation
- [How Scope Derivation Works](/protocol/how-scope-derivation-works-in-cycles) — how scopes are derived from Subjects
# Budget Templates
Ready-to-use setup scripts for common Cycles deployment patterns. Each template creates the tenant, budgets, and API key in one shot. Pick the pattern that matches your deployment, adjust the dollar amounts, and run.
**Prerequisites:**
- Cycles server running ([deployment guide](/quickstart/deploying-the-full-cycles-stack))
- `ADMIN_KEY` set to your admin API key
- `CYCLES_API_KEY` set to an API key with `budgets:write` permission
## Template 1: Single-Tenant (One Organization)
**Use when:** You have one organization, one set of agents, and need basic cost control. The simplest starting point.
**What it creates:**
- 1 tenant
- 1 API key (for your application)
- 1 USD budget at the tenant level ($100/month)
- 1 workspace budget for production ($80/month — leaves $20 for staging/dev)
```bash
#!/bin/bash
# Template 1: Single-Tenant Budget Setup
# Adjust: TENANT, MONTHLY_BUDGET_USD, PROD_BUDGET_USD
ADMIN_URL="http://localhost:7979"
TENANT="my-company"
MONTHLY_BUDGET_USD=100 # $100/month total
PROD_BUDGET_USD=80 # $80/month for production
# Convert to microcents (1 USD = 100,000,000 microcents)
TENANT_BUDGET=$((MONTHLY_BUDGET_USD * 100000000))
PROD_BUDGET=$((PROD_BUDGET_USD * 100000000))
echo "=== Creating tenant ==="
curl -s -X POST "$ADMIN_URL/v1/admin/tenants" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d "{\"tenant_id\": \"$TENANT\", \"name\": \"My Company\"}"
echo
echo "=== Creating API key ==="
curl -s -X POST "$ADMIN_URL/v1/admin/api-keys" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d "{
\"tenant_id\": \"$TENANT\",
\"name\": \"app-server\",
\"permissions\": [\"reservations:create\", \"reservations:commit\", \"reservations:release\", \"reservations:extend\", \"reservations:list\", \"balances:read\"]
}"
echo -e "\n>>> Save the key_secret value above — it won't be shown again\n"
echo "=== Creating tenant-level budget (\$${MONTHLY_BUDGET_USD}/month) ==="
curl -s -X POST "$ADMIN_URL/v1/admin/budgets" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"scope\": \"tenant:$TENANT\",
\"unit\": \"USD_MICROCENTS\",
\"allocated\": {\"amount\": $TENANT_BUDGET, \"unit\": \"USD_MICROCENTS\"}
}"
echo
echo "=== Creating production workspace budget (\$${PROD_BUDGET_USD}/month) ==="
curl -s -X POST "$ADMIN_URL/v1/admin/budgets" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"scope\": \"tenant:$TENANT/workspace:production\",
\"unit\": \"USD_MICROCENTS\",
\"allocated\": {\"amount\": $PROD_BUDGET, \"unit\": \"USD_MICROCENTS\"}
}"
echo
echo "=== Done ==="
echo "Tenant: $TENANT"
echo "Tenant budget: \$$MONTHLY_BUDGET_USD/month"
echo "Production budget: \$$PROD_BUDGET_USD/month"
echo "Remaining for staging/dev: \$$((MONTHLY_BUDGET_USD - PROD_BUDGET_USD))/month"
```
**Monthly reset (cron):**
```bash
# Add to crontab: 0 0 1 * * /path/to/reset-budget.sh
ADMIN_URL="http://localhost:7979"
curl -s -X POST "$ADMIN_URL/v1/admin/budgets/fund?scope=tenant:my-company&unit=USD_MICROCENTS" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"operation\": \"RESET_SPENT\",
\"amount\": {\"amount\": 10000000000, \"unit\": \"USD_MICROCENTS\"},
\"idempotency_key\": \"reset-$(date +%Y-%m)\",
\"reason\": \"Monthly budget reset\"
}"
```
---
## Template 2: Multi-Tenant SaaS (Customer Per Tenant)
**Use when:** You're building a SaaS product where each customer gets their own AI agent access with isolated budgets. The most common production pattern.
**What it creates per customer:**
- 1 tenant per customer
- 1 API key per customer
- 1 USD budget sized by plan tier
- Overdraft policy for Pro/Enterprise plans
```bash
#!/bin/bash
# Template 2: Multi-Tenant SaaS — Customer Onboarding
# Run once per new customer. Adjust: CUSTOMER_ID, PLAN
ADMIN_URL="http://localhost:7979"
CUSTOMER_ID="${1:?Usage: $0 }"
PLAN="${2:?Usage: $0 }" # free | pro | enterprise
# Plan tier budgets (microcents)
case "$PLAN" in
free)
BUDGET=500000000 # $5
OVERDRAFT=0
OVERAGE_POLICY="REJECT"
;;
pro)
BUDGET=5000000000 # $50
OVERDRAFT=500000000 # $5 overdraft
OVERAGE_POLICY="ALLOW_WITH_OVERDRAFT"
;;
enterprise)
BUDGET=50000000000 # $500
OVERDRAFT=5000000000 # $50 overdraft
OVERAGE_POLICY="ALLOW_WITH_OVERDRAFT"
;;
*)
echo "Unknown plan: $PLAN (use: free, pro, enterprise)"
exit 1
;;
esac
echo "=== Onboarding customer: $CUSTOMER_ID (plan: $PLAN) ==="
echo "--- Creating tenant ---"
curl -s -X POST "$ADMIN_URL/v1/admin/tenants" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d "{\"tenant_id\": \"$CUSTOMER_ID\", \"name\": \"$CUSTOMER_ID\"}"
echo
echo "--- Creating API key ---"
curl -s -X POST "$ADMIN_URL/v1/admin/api-keys" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d "{
\"tenant_id\": \"$CUSTOMER_ID\",
\"name\": \"$CUSTOMER_ID-app\",
\"permissions\": [\"reservations:create\", \"reservations:commit\", \"reservations:release\", \"reservations:extend\", \"reservations:list\", \"balances:read\"]
}"
echo -e "\n>>> Save the key_secret value above\n"
echo "--- Creating budget ledger ---"
curl -s -X POST "$ADMIN_URL/v1/admin/budgets" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"scope\": \"tenant:$CUSTOMER_ID\",
\"unit\": \"USD_MICROCENTS\",
\"allocated\": {\"amount\": $BUDGET, \"unit\": \"USD_MICROCENTS\"}
}"
echo
if [ "$OVERDRAFT" -gt 0 ]; then
echo "--- Setting overdraft policy ---"
curl -s -X PATCH "$ADMIN_URL/v1/admin/budgets?scope=tenant:$CUSTOMER_ID&unit=USD_MICROCENTS" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d "{
\"overdraft_limit\": {\"amount\": $OVERDRAFT, \"unit\": \"USD_MICROCENTS\"},
\"commit_overage_policy\": \"$OVERAGE_POLICY\"
}"
echo
fi
echo "=== Customer $CUSTOMER_ID onboarded (plan: $PLAN) ==="
```
**Usage:**
```bash
./onboard-customer.sh acme-corp pro
./onboard-customer.sh small-startup free
./onboard-customer.sh big-enterprise enterprise
```
**Plan tier reference:**
| Plan | Monthly budget | Overdraft | Overage policy |
|---|---|---|---|
| Free | $5 | $0 | REJECT |
| Pro | $50 | $5 | ALLOW_WITH_OVERDRAFT |
| Enterprise | $500 | $50 | ALLOW_WITH_OVERDRAFT |
---
## Template 3: Multi-Agent with RISK_POINTS
**Use when:** Your agents have tools with side effects (email, deploy, database mutations) and you need action-level control beyond cost. Adds a RISK_POINTS budget alongside USD.
**What it creates:**
- 1 tenant
- 1 API key
- 1 USD budget (cost control)
- 1 RISK_POINTS budget (action control — per-run)
- Per-run `RISK_POINTS` cap bounds caller-assigned exposure when every protected tool attempt uses a mandatory reservation boundary
```bash
#!/bin/bash
# Template 3: Multi-Agent with RISK_POINTS
# Adjust: TENANT, USD_BUDGET, RISK_BUDGET_PER_RUN
ADMIN_URL="http://localhost:7979"
TENANT="my-company"
USD_BUDGET=10000000000 # $100/month in microcents (1 USD = 100,000,000)
RISK_BUDGET_PER_RUN=250 # 250 RISK_POINTS per agent run
echo "=== Creating tenant ==="
curl -s -X POST "$ADMIN_URL/v1/admin/tenants" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d "{\"tenant_id\": \"$TENANT\", \"name\": \"My Company\"}"
echo
echo "=== Creating API key ==="
curl -s -X POST "$ADMIN_URL/v1/admin/api-keys" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d "{
\"tenant_id\": \"$TENANT\",
\"name\": \"app-server\",
\"permissions\": [\"reservations:create\", \"reservations:commit\", \"reservations:release\", \"reservations:extend\", \"reservations:list\", \"balances:read\"]
}"
echo -e "\n>>> Save the key_secret value above\n"
echo "=== Creating USD cost budget ($((USD_BUDGET / 100000000))/month) ==="
curl -s -X POST "$ADMIN_URL/v1/admin/budgets" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"scope\": \"tenant:$TENANT\",
\"unit\": \"USD_MICROCENTS\",
\"allocated\": {\"amount\": $USD_BUDGET, \"unit\": \"USD_MICROCENTS\"}
}"
echo
echo "=== Creating per-run RISK_POINTS budget ($RISK_BUDGET_PER_RUN points) ==="
echo " (Create this scope per agent run with the run ID in the scope path)"
echo " Example: tenant:$TENANT/workflow:run-{uuid}"
echo
echo " Use this curl as a template for your application code:"
cat << 'EXAMPLE'
# In your application, before each agent run:
RUN_ID="run-$(uuidgen)"
curl -s -X POST "$ADMIN_URL/v1/admin/budgets" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"scope\": \"tenant:my-company/workflow:$RUN_ID\",
\"unit\": \"RISK_POINTS\",
\"allocated\": {\"amount\": 250, \"unit\": \"RISK_POINTS\"}
}"
EXAMPLE
echo "=== Tool scoring reference ==="
echo " Assign these RISK_POINTS when reserving for each tool call:"
echo
echo " search / read: 0 points (Tier 0)"
echo " save_draft / log: 1 point (Tier 1)"
echo " external API / webhook: 5 points (Tier 2)"
echo " send_email / update_db: 40 points (Tier 3, with 2x multiplier)"
echo " deploy / payment: 150 points (Tier 4, with 3x multiplier)"
echo
echo " Full scoring guide: https://runcycles.io/how-to/assigning-risk-points-to-agent-tools"
echo
echo "=== Done ==="
echo "USD budget: \$$(( USD_BUDGET / 100000000 ))/month (tenant-level)"
echo "RISK_POINTS budget: $RISK_BUDGET_PER_RUN points per run (create per run)"
```
**How the two budgets work together:**
| Budget | Scope | Resets | Controls |
|---|---|---|---|
| USD (cost) | `tenant:my-company` | Monthly via cron | Total API spend across all agents |
| RISK_POINTS (action) | `tenant:my-company/workflow:run-{uuid}` | Per run (new scope each time) | What tools the agent can use within one execution |
The USD budget bounds submitted spend. The `RISK_POINTS` budget separately bounds caller-assigned exposure. In this example, a mandatory handler could reserve 40 points per authorized email attempt, so six attempts consume 240 of 250 points and a seventh does not fit. Tool and argument authorization remains a host responsibility.
---
## Choosing a template
| Template | Best for | Adds | Complexity |
|---|---|---|---|
| **1. Single-Tenant** | Internal tools, solo teams, prototypes | Cost control | Low |
| **2. Multi-Tenant SaaS** | Customer-facing products with plan tiers | Cost control + tenant isolation | Medium |
| **3. Multi-Agent + RISK_POINTS** | Agents with side effects (email, deploy) | Cost + action control | Medium |
**Start with Template 1** to validate your integration. Upgrade to Template 2 when you add customers, or Template 3 when your agents need action-level governance.
## Next steps
- [Common Budget Patterns](/how-to/common-budget-patterns) — deeper patterns beyond these templates
- [Assigning RISK_POINTS to Tools](/how-to/assigning-risk-points-to-agent-tools) — scoring your tools for Template 3
- [Multi-Tenant SaaS Guide](/how-to/multi-tenant-saas-with-cycles) — full multi-tenant architecture
- [Budget Allocation and Management](/how-to/budget-allocation-and-management-in-cycles) — funding, resetting, overdraft policies
- [Shadow Mode Rollout](/how-to/shadow-mode-in-cycles-how-to-roll-out-budget-enforcement-without-breaking-production) — validate budgets before enforcing
# Choosing the Right Integration Pattern
Each Cycles SDK offers multiple integration patterns. This guide helps you pick the right one for your use case.
## Decision tree
## Pattern comparison
| Pattern | Languages | Best for | Streaming | Auto-heartbeat | Auto-commit |
|---|---|---|---|---|---|
| **MCP Server** | Any (agent-native) | Cooperative budget-tool exposure in MCP hosts | — | — | — |
| **Agent framework plugin** | Python, TypeScript | Agent SDKs with lifecycle hooks | — | Yes | Yes |
| **Decorator / HOF** | Python `@cycles`, TS `withCycles`, Java `@Cycles` | Simple function calls | No | Yes | Yes |
| **Streaming adapter** | Python `stream_reservation`, TS `reserveForStream` | Streaming responses | Yes | Yes | Manual |
| **Middleware** | Express, FastAPI | Per-request budget in web apps | Both | Depends | Manual |
| **Programmatic client** | All languages | Full control, complex flows | Both | Manual | Manual |
## Pattern 0: MCP Server (zero-code tool exposure)
If your agent runs in an MCP-compatible host — Claude Desktop, Claude Code, Cursor, or Windsurf — you can expose Cycles tools without an SDK integration. This is cooperative: the standalone MCP server does not automatically wrap or block the host's other tools.
```bash
# Claude Code
claude mcp add \
--transport stdio \
--env CYCLES_API_KEY=cyc_live_... \
--env CYCLES_BASE_URL=http://localhost:7878 \
cycles \
-- npx -y @runcycles/mcp-server
```
The agent may call `cycles_reserve`, `cycles_commit`, and other tools as part of its reasoning. No application code wraps the LLM call, so this alone is not a hard limit. Use **Cycles Budget Guard for Claude Code** or a mandatory handler, gateway, harness, or service boundary when the protected action must not bypass the reservation.
**Use when:**
- The agent host supports MCP
- You want budget awareness with zero code changes
- Cooperative, model-managed budget lifecycle is acceptable
**Don't use when:**
- You're building a non-agent application (web API, batch pipeline)
- You need a hard limit but cannot add a mandatory host or application boundary
See [Getting Started with the MCP Server](/quickstart/getting-started-with-the-mcp-server) for setup instructions.
## Pattern 0a: Agent framework plugin
For agent frameworks that expose lifecycle hooks, a plugin implements the framework's hook interface to create reservations on start and commit on end — covering the entire agent run automatically with no per-function decoration.
| Framework | Plugin / package | Hook surface |
|---|---|---|
| OpenAI Agents SDK | `runcycles_openai_agents.CyclesRunHooks` | `RunHooks` interface |
| OpenClaw | Plugin hooks | `before_model_resolve`, `before_tool_call`, etc. |
| **LangChain 1.x** (`langchain.agents.create_agent`) | [**`langchain-runcycles`**](https://pypi.org/project/langchain-runcycles/) — `CyclesModelGate`, `CyclesToolGate`, `CyclesFanOutGate` | `wrap_model_call`, `wrap_tool_call`, `before_model` (`AgentMiddleware` API) |
```python
# LangChain 1.x agent middleware
from langchain.agents import create_agent
from langchain_runcycles import CyclesFanOutGate, CyclesToolGate
from runcycles import Action, Subject
agent = create_agent(
model="claude-sonnet-4-6",
tools=[...],
middleware=[
CyclesFanOutGate(max_turns=20, client=client, subject=Subject(tenant="acme"), action=Action(kind="model.turn", name="research")),
CyclesToolGate(client, subject=Subject(tenant="acme"), action={"send_email": Action(kind="tool.call", name="send_email")}, mode="decide"),
],
)
```
```python
# OpenAI Agents SDK
from agents import Agent
from runcycles_openai_agents import CyclesRunHooks
hooks = CyclesRunHooks(
tenant="acme",
tool_estimates={"send_email": 50, "search": 0}, # default unit: RISK_POINTS
)
result = await hooks.run(agent, input="...")
```
**Use when:**
- You're using an agent framework with lifecycle hooks (OpenAI Agents SDK, OpenClaw, LangChain 1.x `create_agent`)
- You want automatic budget governance on LLM and tool calls, plus best-effort handoff audit events
- You need tool-level risk mapping (different costs per tool)
- You want agent handoff tracking in the Cycles ledger
**Don't use when:**
- You're not using an agent framework (use `@cycles` decorator instead)
- You need per-function control over estimation and commit (use programmatic client)
- You're using bare LangChain (`ChatOpenAI`, chains, RAG) without `create_agent` — use the [LangChain callback handler](/how-to/integrating-cycles-with-langchain#callback-handler-for-non-agent-runnables) instead
See [Integrating with LangChain](/how-to/integrating-cycles-with-langchain) (Python agent middleware), [OpenAI Agents](/how-to/integrating-cycles-with-openai-agents), or [OpenClaw](/how-to/integrating-cycles-with-openclaw).
## Pattern 1: Decorator / Higher-Order Function
The simplest pattern. Wrap a function and let the SDK handle the full reserve-execute-commit lifecycle.
::: code-group
```python [Python]
@cycles(estimate=2000000, action_kind="llm.completion", action_name="gpt-4o")
def ask(prompt: str) -> str:
return openai.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
).choices[0].message.content
```
```typescript [TypeScript]
const ask = withCycles(
{ estimate: 2000000, actionKind: "llm.completion", actionName: "gpt-4o" },
async (prompt: string) => {
const res = await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: prompt }],
});
return res.choices[0].message.content;
},
);
```
```java [Java]
@Cycles(estimate = "2000000", actionKind = "llm.completion", actionName = "gpt-4o")
public String ask(String prompt) {
return callOpenAI(prompt);
}
```
:::
**Use when:**
- The function makes one LLM/API call and returns a result
- You don't need to stream the response
- You want minimal code changes
**Don't use when:**
- The function streams output (use a streaming adapter instead)
- You need to control when the commit happens (use programmatic client)
Note that the decorator *can* commit with actual token-derived costs: both SDKs accept an `actual` callable that receives the function's return value — `@cycles(estimate=..., actual=lambda result: len(result) * 5)` in Python, `withCycles({ estimate: ..., actual: (result) => result.usage.total_tokens * 10 }, ...)` in TypeScript.
## Pattern 2: Streaming adapter
For streaming responses where the function returns before the stream finishes.
### TypeScript (`reserveForStream`)
```typescript
const handle = await reserveForStream({
client: cyclesClient,
estimate: 5000000,
actionKind: "llm.completion",
actionName: "gpt-4o",
});
try {
const stream = await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: prompt }],
stream: true,
});
let inputTokens = 0, outputTokens = 0;
for await (const chunk of stream) {
// ... consume stream, track tokens ...
}
await handle.commit(actualCost, { tokensInput: inputTokens, tokensOutput: outputTokens });
} catch (err) {
await handle.release("stream_error");
throw err;
}
```
### Python (`stream_reservation`)
The Python client's equivalent is `client.stream_reservation(...)` — a context manager that reserves on enter and commits (or releases, on exception) on exit:
```python
with client.stream_reservation(
action=Action(kind="llm.completion", name="gpt-4o"),
estimate=Amount(unit=Unit.USD_MICROCENTS, amount=5_000_000),
cost_fn=lambda u: u.tokens_input * 250 + u.tokens_output * 1000,
) as reservation:
for chunk in stream:
# ... consume stream, track tokens ...
reservation.usage.add_output_tokens(chunk_tokens)
# Auto-committed on success, auto-released on exception.
```
**Use when:**
- The LLM response is streamed to the client
- You need to track token counts from stream events
- You want automatic heartbeat during streaming
**Don't use when:**
- The response is not streamed (use `withCycles` instead — simpler)
## Pattern 3: Middleware
For web applications where every request needs budget governance.
::: code-group
```typescript [Express]
// cyclesGuard is an example pattern (not exported by the SDK) — build it from
// the programmatic client; see Integrating Cycles with Express for the full source.
app.post("/api/chat", cyclesGuard({ client, actionKind: "llm.completion", ... }), handler);
```
```python [FastAPI]
@app.post("/api/chat")
@cycles(estimate=2000000, action_kind="llm.completion", action_name="gpt-4o")
async def chat(request: ChatRequest):
...
```
:::
See [Integrating Cycles with Express](/how-to/integrating-cycles-with-express) for a complete `cyclesGuard` middleware implementation.
**Use when:**
- Budget enforcement should apply to every request on a route
- You want your application to translate Cycles' HTTP 409 `BUDGET_EXCEEDED`
response into an HTTP 402 response for callers
- Budget should be scoped per-request (e.g., per-tenant)
**Don't use when:**
- Budget logic varies significantly between requests on the same route
- You're not in a web framework context
## Pattern 4: Programmatic client
Full control over the reserve-commit lifecycle. Use this when no higher-level pattern fits.
::: code-group
```python [Python]
client = CyclesClient(config)
reservation = client.create_reservation({
"idempotency_key": "req-001",
"subject": {"tenant": "acme-corp"},
"action": {"kind": "llm.completion", "name": "gpt-4o"},
"estimate": {"amount": 2000000, "unit": "USD_MICROCENTS"},
"ttl_ms": 30000,
})
if reservation.status == 409:
# Live (non-dry-run) denials are HTTP 409 BUDGET_EXCEEDED — there is
# no decision field to check (decision=DENY only appears when dry_run=true).
handle_denial()
elif reservation.is_success:
reservation_id = reservation.body["reservation_id"]
result = call_llm()
client.commit_reservation(reservation_id, {
"idempotency_key": "commit-001",
"actual": {"amount": actual_cost, "unit": "USD_MICROCENTS"},
})
```
```typescript [TypeScript]
// The TypeScript client sends and receives wire-format (snake_case) JSON.
const reservation = await client.createReservation({
idempotency_key: "req-001",
subject: { tenant: "acme-corp" },
action: { kind: "llm.completion", name: "gpt-4o" },
estimate: { amount: 2000000, unit: "USD_MICROCENTS" },
ttl_ms: 30000,
});
if (reservation.status === 409) {
// Live (non-dry-run) denials are HTTP 409 BUDGET_EXCEEDED — there is
// no decision field to check (decision=DENY only appears when dry_run=true).
handleDenial();
} else if (reservation.isSuccess) {
const reservationId = reservation.getBodyAttribute("reservation_id") as string;
const result = await callLLM();
await client.commitReservation(reservationId, {
idempotency_key: "commit-001",
actual: { amount: actualCost, unit: "USD_MICROCENTS" },
});
}
```
:::
**Use when:**
- You need to inspect the reservation decision before proceeding
- You need to commit with exact actual token counts
- You're building a custom integration layer
- You need to manage TTL extensions manually
- The operation spans multiple steps with different commit points
**Don't use when:**
- A decorator or streaming adapter would work — they handle heartbeat, retry, and cleanup automatically
## Combining patterns
In practice, most applications use multiple patterns:
```python
# Simple calls — decorator
@cycles(estimate=500000, action_kind="llm.completion", action_name="gpt-4o-mini")
def classify(text: str) -> str:
...
# Complex flows — programmatic
async def agent_loop(task: str):
client = CyclesClient(config)
while not done:
reservation = client.create_reservation(...)
result = call_tool(...)
client.commit_reservation(...)
```
## Next steps
- [Getting Started with the MCP Server](/quickstart/getting-started-with-the-mcp-server) — zero-code runtime authority for Claude / Cursor / Windsurf
- [Integrating with OpenAI Agents](/how-to/integrating-cycles-with-openai-agents) — budget governance for OpenAI Agents SDK
- [Getting Started with Python](/quickstart/getting-started-with-the-python-client)
- [Getting Started with TypeScript](/quickstart/getting-started-with-the-typescript-client)
- [Getting Started with Spring Boot](/quickstart/getting-started-with-the-cycles-spring-boot-starter)
- [Handling Streaming Responses](/how-to/handling-streaming-responses-with-cycles)
# Choosing the Right Overage Policy
Cycles provides three overage policies that control what happens when actual usage exceeds the reserved estimate. This guide helps you pick the right one based on your use case.
For detailed mechanics and implementation, see [Commit Overage Policies](/protocol/commit-overage-policies-in-cycles-reject-allow-if-available-and-allow-with-overdraft).
## Decision flowchart
Ask these questions in order:
1. **Has the work already happened?** (external import, retroactive accounting, side effect already fired)
→ **ALLOW_WITH_OVERDRAFT** — the ledger must reflect reality. Note that debt is bounded by the scope's `overdraft_limit`: if the commit would push debt beyond it, the server rejects it with `409 OVERDRAFT_LIMIT_EXCEEDED`, so size the limit for your import volumes
2. **Can you estimate costs reliably?** (fixed-price APIs, known token counts, deterministic operations)
→ **REJECT** with a 10–20% buffer — hard enforcement is safe when estimates are tight
3. **Everything else** — variable-cost LLM calls, tool invocations, streaming responses, multi-step agents
→ **ALLOW_IF_AVAILABLE** (the default) — does not reject a valid commit merely because of overage, never creates debt, and caps the charged delta at available budget
## By use case
### LLM completions (GPT-4, Claude, Gemini)
**Recommended:** ALLOW_IF_AVAILABLE
Token counts vary by prompt, context window, and model behavior. Estimation is inherently imprecise. For an instrumented call whose commit passes ownership, state, expiry, and unit validation, ALLOW_IF_AVAILABLE records the submitted actual but caps the charged overage when budget runs low.
```python
@cycles(estimate=50000, action_kind="llm.completion", action_name="openai:gpt-4o")
def call_llm(prompt: str) -> str:
return openai_client.chat(prompt)
```
### Tool invocations with known costs
**Recommended:** REJECT or ALLOW_IF_AVAILABLE
If a tool call has a fixed, predictable cost (e.g., a search API at $0.01/query), REJECT with a buffer works well. If cost varies, use ALLOW_IF_AVAILABLE.
```python
# Fixed cost — REJECT is safe
@cycles(estimate=1000, overage_policy="REJECT", action_kind="tool.search", action_name="google-search")
def search(query: str) -> list:
return search_api.query(query)
```
### External usage imports
**Recommended:** ALLOW_WITH_OVERDRAFT
When importing usage from an external billing system, the work already happened. The budget ledger must reflect it regardless of remaining budget.
```python
from runcycles import EventCreateRequest, Subject, Action, Amount
# Recording usage from an external gateway
client.create_event(EventCreateRequest(
idempotency_key="import-ext-001",
subject=Subject(tenant="acme", app="gateway"),
action=Action(kind="llm.completion", name="external:model"),
actual=Amount(amount=usage_amount, unit="USD_MICROCENTS"),
overage_policy="ALLOW_WITH_OVERDRAFT"
))
```
### Multi-step agent workflows
**Recommended:** ALLOW_IF_AVAILABLE
Agents that chain multiple LLM calls and tool invocations have highly variable total cost. ALLOW_IF_AVAILABLE lets each step commit without risk of rejection, while the budget boundary naturally stops new reservations when funds run out.
### Multi-tenant platforms
**Recommended:** ALLOW_IF_AVAILABLE as the tenant default
Set `default_commit_overage_policy: ALLOW_IF_AVAILABLE` at the tenant level. This gives tenants a safe default — no debt, no rejected commits — while individual requests can override to REJECT or ALLOW_WITH_OVERDRAFT as needed.
### SLA-critical operations
**Recommended:** ALLOW_WITH_OVERDRAFT
When an operation must succeed even when the budget is exhausted — critical alerts, compliance actions, safety-related tool calls — use ALLOW_WITH_OVERDRAFT so budget exhaustion alone does not block the action. This is not unlimited: debt is bounded by the scope's `overdraft_limit`, and a commit that would push debt beyond that limit is rejected with `409 OVERDRAFT_LIMIT_EXCEEDED`. Set an `overdraft_limit` large enough to cover your worst-case SLA-critical burst, and monitor debt so it is repaid before the limit is reached.
### Background batch processing
**Recommended:** REJECT
For batch jobs where individual items can be retried, REJECT provides hard budget control. If an item exceeds budget, skip it and retry later when budget is replenished.
## Mixing policies
Most production systems use different policies for different action classes:
| Action class | Policy | Why |
|---|---|---|
| LLM completions | ALLOW_IF_AVAILABLE | Variable cost, always record |
| Fixed-cost tools | REJECT | Predictable, retry-safe |
| External imports | ALLOW_WITH_OVERDRAFT | Already happened |
| Agent orchestration | ALLOW_IF_AVAILABLE | Variable, multi-step |
| Safety-critical ops | ALLOW_WITH_OVERDRAFT | Not blocked by exhaustion (up to `overdraft_limit`) |
## Setting the default
The overage policy resolves in this order (most specific wins):
1. **Request-level** `overage_policy` field — per-call override on the reservation or event
2. **Policy-level** — pattern-based override configured via the Admin API
3. **Budget ledger-level** `commit_overage_policy` — override for a specific scope's ledger
4. **Tenant default** `default_commit_overage_policy` — set via Admin API
If none of these is set, the server falls back to `ALLOW_IF_AVAILABLE`.
For most teams, leaving the default as ALLOW_IF_AVAILABLE and overriding per-request for specific action classes is the simplest approach.
## Summary
- **REJECT** — hard stops, best when estimates are reliable, may leave unaccounted gaps
- **ALLOW_IF_AVAILABLE** — safe default, always commits, caps at budget, no debt
- **ALLOW_WITH_OVERDRAFT** — ledger accuracy above all, creates debt, requires reconciliation
When in doubt, use ALLOW_IF_AVAILABLE. It handles the widest range of scenarios without operational overhead.
## Next steps
- [Commit Overage Policies](/protocol/commit-overage-policies-in-cycles-reject-allow-if-available-and-allow-with-overdraft) — detailed mechanics and concurrency semantics
- [Cost Estimation Cheat Sheet](/how-to/cost-estimation-cheat-sheet) — how estimation strategy ties to overage policy
- [Debt, Overdraft, and the Over-Limit Model](/protocol/debt-overdraft-and-the-over-limit-model-in-cycles) — understanding debt and reconciliation
- [How Events Work](/protocol/how-events-work-in-cycles-direct-debit-without-reservation) — direct debit without reservation
# Client Performance Tuning
The default client configuration works well for most workloads. This guide covers when and how to tune for high-throughput or latency-sensitive scenarios.
::: tip When to tune
Consider tuning when you observe any of these:
- Concurrent reservation count regularly exceeds 100
- Client-side p99 latency exceeds 50ms
- Commit retry rate exceeds 5%
- Connection timeout errors in client logs
For server-side benchmarks and baseline expectations, see the [Performance Benchmarks](/blog/cycles-server-performance-benchmarks) blog post.
:::
## Timeout tuning
All four clients configure two timeouts: connection (TCP handshake) and read (waiting for server response).
### Default values
| Setting | Python | TypeScript | Spring Boot | Rust |
|---|---|---|---|---|
| Connect timeout | `connect_timeout=2.0` (seconds) | `connectTimeout=2000` (ms) | `cycles.http.connect-timeout=2s` | `connect_timeout=2s` |
| Read timeout | `read_timeout=5.0` (seconds) | `readTimeout=5000` (ms) | `cycles.http.read-timeout=5s` | `read_timeout=5s` |
::: info TypeScript timeout behavior
Node's built-in `fetch` does not distinguish connection from read timeout. `connectTimeout` and `readTimeout` are summed into a single `AbortSignal.timeout()` value (default: 7000ms total).
:::
::: warning Heartbeat timeout budget
Automatic heartbeat reserves two complete request-attempt budgets plus a safety margin inside the remaining lease. Keep the enforced attempt timeout well below half the shortest expected TTL. The current defaults produce a finite 7-second attempt budget in TypeScript, Spring Boot, and Rust, and a 12-second whole-attempt deadline in Python; a 30-second attempt timeout leaves no positive cadence inside the default 60-second lease.
:::
### Tuning profiles
Choose a profile based on your deployment topology:
**Co-located (client and server on same network):**
```python
# Python
config = CyclesConfig(
base_url="http://cycles-server:7878",
api_key="cyc_live_...",
connect_timeout=0.5, # 500ms — same-network handshake is fast
read_timeout=2.0, # 2s — server p99 is <5ms, leave headroom
)
```
```typescript
// TypeScript
const config = new CyclesConfig({
baseUrl: "http://cycles-server:7878",
apiKey: "cyc_live_...",
connectTimeout: 500,
readTimeout: 2000,
});
```
```yaml
# Spring Boot
cycles:
http:
connect-timeout: 500ms
read-timeout: 2s
```
```rust
// Rust
use runcycles::CyclesClient;
use std::time::Duration;
let client = CyclesClient::builder("cyc_live_...", "http://cycles-server:7878")
.connect_timeout(Duration::from_millis(500))
.read_timeout(Duration::from_secs(2))
.build();
```
**Cross-region or high-latency network:**
```python
# Python
config = CyclesConfig(
base_url="https://cycles.us-east.example.com",
api_key="cyc_live_...",
connect_timeout=5.0, # TLS handshake across regions
read_timeout=15.0, # account for network jitter
)
```
```yaml
# Spring Boot
cycles:
http:
connect-timeout: 5s
read-timeout: 15s
```
**High-throughput with aggressive retry:**
```python
# Python — fail fast, retry quickly
config = CyclesConfig(
base_url="http://cycles-server:7878",
api_key="cyc_live_...",
connect_timeout=1.0,
read_timeout=3.0,
retry_max_attempts=10,
retry_initial_delay=0.1,
retry_multiplier=1.5,
retry_max_delay=5.0,
)
```
::: warning
Never set the read timeout below the server's expected p99 latency. At default load, the server's p99 for a reserve-commit cycle is ~20ms. Under heavy load it can reach 50ms+. A read timeout of 100ms will cause spurious failures.
:::
## Connection pooling
### Python (httpx)
The Python client uses `httpx.Client`, which manages a connection pool automatically. Key details:
- Connections are reused across requests (HTTP keep-alive)
- Pool timeout is hardcoded at 5 seconds (time to acquire a connection from the pool)
- Write timeout is hardcoded at 5 seconds
- httpx uses its default limits: 100 max connections, of which up to 20 are kept alive for reuse — with no per-host cap (all connections can go to the single Cycles server)
The constructor accepts only a `CyclesConfig` — there is no parameter for injecting a custom httpx transport or client, so the pool limits are not tunable per client. For most workloads, the defaults are sufficient. If you see `PoolTimeout` errors, you have more than 100 concurrent requests in flight on a single client instance. Solutions:
1. **Use multiple client instances** — partition by tenant or workload so each instance carries a share of the concurrency (each gets its own 100-connection pool).
2. **Bound your concurrency** — cap in-flight Cycles calls below the pool size (e.g. an `asyncio.Semaphore` around reservation calls, or a bounded worker pool).
### TypeScript (fetch)
The TypeScript client uses Node's built-in `fetch`, which relies on the runtime's HTTP agent for connection reuse:
- Node.js 20+ reuses connections automatically via its global `undici` agent
- No explicit pool configuration is exposed
- Keep-alive is enabled by default
For high-throughput Node.js services, ensure you're running Node 20+ where `fetch` connection reuse is reliable.
### Spring Boot (Reactor Netty)
The Spring Boot starter uses `WebClient` backed by Reactor Netty's `HttpClient`. Reactor Netty manages its own connection pool:
- Default pool: Reactor Netty's shared global pool — max connections is `2 × max(available processors, 8)` (so 16 on small hosts), with no idle timeout by default
- The 45-second default is the **pending-acquire timeout**: how long a request waits to acquire a connection from the pool before failing, not an idle timeout
- Configure via Reactor Netty system properties (`reactor.netty.pool.maxConnections`, `reactor.netty.pool.acquireTimeout`) or provide a custom `WebClient` bean
To customize the connection pool:
```java
@Bean
public WebClient cyclesWebClient(CyclesProperties props) {
ConnectionProvider provider = ConnectionProvider.builder("cycles")
.maxConnections(200)
.pendingAcquireTimeout(Duration.ofSeconds(10)) // default: 45s
.maxIdleTime(Duration.ofSeconds(30))
.build();
HttpClient httpClient = HttpClient.create(provider)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS,
(int) props.getHttp().getConnectTimeout().toMillis())
.responseTimeout(props.getHttp().getReadTimeout());
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.baseUrl(props.getBaseUrl())
.defaultHeader("X-Cycles-API-Key", props.getApiKey())
.build();
}
```
## Retry strategy tuning
The settlement retry engine handles transport failures, 5xx responses, and rate limits with same-key recovery. Known actual usage is also journaled before the first settlement request, so retry tuning changes how quickly the client retries; it does not remove restart durability while journaling remains enabled. Two common profiles:
### Fast-fail (latency-sensitive paths)
Limit in-process retry work when prolonged recovery is undesirable. In Rust this directly bounds the inline guarded-commit wait; the Python, TypeScript, and Spring lifecycle helpers schedule settlement recovery in the background.
| Setting | Python | TypeScript | Spring Boot | Rust |
|---|---|---|---|---|
| Max attempts | `retry_max_attempts=2` | `retryMaxAttempts: 2` | `cycles.retry.max-attempts=2` | `.retry_max_attempts(2)` |
| Initial delay | `retry_initial_delay=0.1` | `retryInitialDelay: 100` | `cycles.retry.initial-delay=100ms` | `.retry_initial_delay(Duration::from_millis(100))` |
| Multiplier | `retry_multiplier=1.5` | `retryMultiplier: 1.5` | `cycles.retry.multiplier=1.5` | `.retry_multiplier(1.5)` |
| Max delay | `retry_max_delay=1.0` | `retryMaxDelay: 1000` | `cycles.retry.max-delay=1s` | `.retry_max_delay(Duration::from_secs(1))` |
| Scheduled backoff | ~250ms | ~250ms | ~250ms | ~250ms |
### Aggressive convergence (must-commit workloads)
Retry aggressively because the action already happened and the ledger must reflect it.
| Setting | Python | TypeScript | Spring Boot | Rust |
|---|---|---|---|---|
| Max attempts | `retry_max_attempts=10` | `retryMaxAttempts: 10` | `cycles.retry.max-attempts=10` | `.retry_max_attempts(10)` |
| Initial delay | `retry_initial_delay=0.2` | `retryInitialDelay: 200` | `cycles.retry.initial-delay=200ms` | `.retry_initial_delay(Duration::from_millis(200))` |
| Multiplier | `retry_multiplier=1.5` | `retryMultiplier: 1.5` | `cycles.retry.multiplier=1.5` | `.retry_multiplier(1.5)` |
| Max delay | `retry_max_delay=60.0` | `retryMaxDelay: 60000` | `cycles.retry.max-delay=60s` | `.retry_max_delay(Duration::from_secs(60))` |
| Scheduled backoff | ~23 seconds | ~23 seconds | ~23 seconds | ~23 seconds |
Scheduled backoff excludes the time spent in the initial request and each retry attempt. A valid server `Retry-After` can also lengthen the schedule according to the SDK's bounded-delay policy.
### When to disable retry
```python
config = CyclesConfig(base_url="...", api_key="...", retry_enabled=False)
```
Disable retry when:
- You handle retries at a higher level (e.g. job queue with built-in retry)
- You need deterministic latency with no background work
- Testing, where retry masks failures
Disabling retry does not disable the durable journal. Pending known-actual settlement remains available for replay on a later client start. Disable journaling only when the higher-level system durably persists the exact settlement body and idempotency key and implements equivalent replay and expiry fallback.
## Anti-patterns
### Creating a new client per request
Every `CyclesClient` instance creates a new HTTP connection pool. Creating one per request wastes connections and prevents reuse.
```python
# BAD — new client (and connection pool) for every call
@cycles(estimate=1000, client=CyclesClient(config))
def process(text: str) -> str:
return call_llm(text)
```
```python
# GOOD — reuse a single client via module default
client = CyclesClient(config)
set_default_client(client)
@cycles(estimate=1000)
def process(text: str) -> str:
return call_llm(text)
```
The same applies in TypeScript (`setDefaultClient`) and Spring Boot (the auto-configured `CyclesClient` bean is a singleton).
### Expensive estimate computations in the hot path
The estimate callable runs synchronously before each reservation. Keep it fast:
```python
# BAD — network call in the estimate
@cycles(estimate=lambda text: fetch_token_count_from_api(text))
def process(text: str) -> str:
return call_llm(text)
```
```python
# GOOD — pre-compute or use a fast heuristic
@cycles(estimate=lambda text: len(text) // 4) # ~4 characters per token
def process(text: str) -> str:
return call_llm(text)
```
## High-throughput checklist
1. **Reuse a single client instance** across all requests (all 3 clients)
2. **Warm up on startup** — make a lightweight call through the client so its connection pool is established before real traffic arrives:
```python
client = CyclesClient(config)
client.get_balances(tenant="my-tenant") # any cheap read warms the pool
```
To check server availability without an API key, use the public readiness probe (`GET /actuator/health/readiness`). Note that the aggregate `/actuator/health` and other actuator endpoints require the admin key since server 0.1.25.45 — only the liveness and readiness probes are public.
3. **Graceful shutdown** — commit or release active reservations before process exit
4. **Pre-compute estimates** outside the decorator/HOF hot path
5. **Lower timeouts** if co-located, raise if cross-region
6. **Use durable retry** for must-commit workloads
7. **Monitor** retry rates and timeout errors — rising rates signal infrastructure issues
## Server-side tuning
For server-side performance:
- **Redis connection pool** — default 128 connections. See [Production Operations Guide](/how-to/production-operations-guide).
- **Expiry sweep interval** — default 5000ms. See [Server Configuration Reference](/configuration/server-configuration-reference-for-cycles).
- **Benchmarks** — Reserve 5.3ms p50, 2,632 reserve-commit lifecycles/sec at 32 threads. See [Performance Benchmarks](/blog/cycles-server-performance-benchmarks).
## Next steps
- [Production Operations Guide](/how-to/production-operations-guide) — server infrastructure and Redis tuning
- [Monitoring and Alerting](/how-to/monitoring-and-alerting) — metrics and alerting setup
- [Observability Setup](/how-to/observability-setup) — Prometheus, Grafana, and Datadog integration
- [Python Client Configuration](/configuration/python-client-configuration-reference) — all Python config options
- [TypeScript Client Configuration](/configuration/typescript-client-configuration-reference) — all TypeScript config options
- [Spring Client Configuration](/configuration/client-configuration-reference-for-cycles-spring-boot-starter) — all Spring Boot config options
# Common Budget Patterns
Practical recipes for common budget governance scenarios. Each pattern shows the scope hierarchy and budget allocation needed.
::: tip Need cost estimates?
See the [Cost Estimation Cheat Sheet](/how-to/cost-estimation-cheat-sheet) for per-model pricing and how to translate token counts into `USD_MICROCENTS`.
:::
## Per-user daily budgets
Give each user a daily spending limit.
**Scope:** `tenant:acme-corp/workspace:prod/app:chatbot/agent:{user_id}`
```bash
# Create a $5/day budget for user-123
curl -s -X POST http://localhost:7979/v1/admin/budgets \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"scope": "tenant:acme-corp/workspace:prod/app:chatbot/agent:user-123",
"unit": "USD_MICROCENTS",
"allocated": { "amount": 500000000, "unit": "USD_MICROCENTS" }
}' | jq .
```
**Reset daily** with a cron job or scheduled task — use `RESET_SPENT` so
each day's spent clears to 0 (not `RESET`, which preserves spent and
would leave the budget exhausted):
```bash
# Reset each user's daily budget to $5, clearing yesterday's spend
curl -s -X POST ".../fund" \
-d '{"operation": "RESET_SPENT", "amount": {"amount": 500000000, "unit": "USD_MICROCENTS"}, ...}'
```
**In your app:**
```python
@cycles(
estimate=2000000,
action_kind="llm.completion",
action_name="gpt-4o",
# Callable: re-evaluated on every call, so each request resolves
# the current user (a bare `current_user.id` would be captured
# once at decoration time)
agent=lambda prompt: current_user.id,
)
def chat(prompt: str) -> str:
...
```
## Per-conversation session budgets
Cap spending per conversation to prevent runaway loops.
**Scope:** `tenant:acme-corp/workflow:{conversation_id}`
```bash
# Create a $0.50 budget per conversation
curl -s -X POST http://localhost:7979/v1/admin/budgets \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"scope": "tenant:acme-corp/workflow:conv-abc-123",
"unit": "USD_MICROCENTS",
"allocated": { "amount": 50000000, "unit": "USD_MICROCENTS" }
}' | jq .
```
**In your app:**
```python
@cycles(
estimate=2000000,
action_kind="llm.completion",
action_name="gpt-4o",
# Callable receives the function's arguments at call time
workflow=lambda conversation_id, message: conversation_id,
)
def reply(conversation_id: str, message: str) -> str:
...
```
When the conversation budget runs out, the next call is denied. The user sees a "budget exhausted" message and can start a new conversation (with its own fresh budget).
## Model-tier budgets
Different budget pools for different model tiers. Prevents expensive model calls from consuming the cheap-model budget.
**Scopes:**
```
tenant:acme-corp/app:chatbot/toolset:tier-premium → $50/month
tenant:acme-corp/app:chatbot/toolset:tier-standard → $200/month
tenant:acme-corp/app:chatbot/toolset:tier-economy → $500/month
```
**In your app:**
```python
MODEL_TIERS = {
"gpt-4o": "tier-premium",
"claude-sonnet": "tier-premium",
"gpt-4o-mini": "tier-standard",
"claude-haiku": "tier-economy",
}
@cycles(
estimate=2000000,
action_kind="llm.completion",
# Callables: resolved per call from the function's arguments
action_name=lambda model_name, prompt: model_name,
toolset=lambda model_name, prompt: MODEL_TIERS[model_name],
)
def call_model(model_name: str, prompt: str) -> str:
...
```
## Team-level rollup budgets
Give each team its own budget while also enforcing a company-wide cap.
**Scopes (both need budgets):**
```
tenant:acme-corp → $10,000/month (company cap)
tenant:acme-corp/workspace:engineering → $5,000/month
tenant:acme-corp/workspace:marketing → $2,000/month
tenant:acme-corp/workspace:support → $3,000/month
```
A reservation with `tenant=acme-corp, workspace=engineering` checks budget at both levels. If the engineering team has budget but the company is at its cap, the reservation is denied.
## Agent loop with per-run budget
Cap the total cost of a single agent run to prevent runaway loops.
```bash
# Create a $2 budget for this specific run
curl -s -X POST http://localhost:7979/v1/admin/budgets \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"scope": "tenant:acme-corp/workflow:run-xyz-789",
"unit": "USD_MICROCENTS",
"allocated": { "amount": 200000000, "unit": "USD_MICROCENTS" }
}' | jq .
```
**In your app:**
```python
def agent_run(task: str, run_id: str):
while not done:
@cycles(
estimate=2000000,
action_kind="llm.completion",
action_name="gpt-4o",
workflow=run_id,
)
def think(prompt: str) -> str:
return call_llm(prompt)
try:
result = think(next_prompt)
# ... process result, decide next step ...
except BudgetExceededError:
return "Agent stopped: budget limit for this run reached."
```
## Gradual degradation pattern
Use multiple budget thresholds to degrade gracefully instead of hard-stopping.
**Budget scopes with different allocations:**
```
tenant:acme-corp/app:chatbot → $100 (hard limit)
tenant:acme-corp/app:chatbot/toolset:premium → $60 (premium model threshold)
tenant:acme-corp/app:chatbot/toolset:tools → $40 (tool use threshold)
```
**In your app:**
```python
from runcycles import BudgetExceededError, cycles
@cycles(estimate=5000000, action_kind="llm.completion",
action_name="gpt-4o", toolset="premium")
def premium_response(prompt: str) -> str:
return call_gpt4o(prompt)
@cycles(estimate=200000, action_kind="llm.completion",
action_name="gpt-4o-mini")
def economy_response(prompt: str) -> str:
return call_gpt4o_mini(prompt)
def respond(prompt: str) -> str:
# Try premium model first
try:
return premium_response(prompt)
except BudgetExceededError:
pass # Premium budget exhausted, fall through
# Fall back to cheap model
try:
return economy_response(prompt)
except BudgetExceededError:
return "All budgets exhausted. Please try again later."
```
## Multi-tenant SaaS with per-customer budgets
Each customer gets an isolated budget. Use tenant-per-customer or workspace-per-customer depending on your isolation model.
**Option A: Tenant per customer** (strongest isolation — separate API keys)
```
tenant:customer-a → $500/month
tenant:customer-b → $200/month
```
**Option B: Workspace per customer** (shared tenant, simpler management)
```
tenant:my-saas/workspace:customer-a → $500/month
tenant:my-saas/workspace:customer-b → $200/month
```
The app resolves the scope from the authenticated request:
```python
@cycles(
estimate=2000000,
action_kind="llm.completion",
action_name="gpt-4o",
# Callable: resolves the customer per call
workspace=lambda request: request.customer_id,
)
def handle_request(request):
...
```
## Next steps
- [Tenants, Scopes, and Budgets](/how-to/understanding-tenants-scopes-and-budgets-in-cycles) — how the three building blocks fit together
- [Tenant, Workflow, and Run Budgets](/how-to/how-to-model-tenant-workflow-and-run-budgets-in-cycles) — detailed multi-level budgeting guide
- [Budget Allocation and Management](/how-to/budget-allocation-and-management-in-cycles) — funding operations
- [Scope Derivation](/protocol/how-scope-derivation-works-in-cycles) — how hierarchical scopes work
- [AI Agent Budget Patterns: A Practical Guide](/blog/agent-budget-patterns-visual-guide) — architectural thinking behind each budget pattern
# Cost Estimation Cheat Sheet
This guide answers the most common question when adopting Cycles: **how much should I reserve for a given LLM call?**
For the broader strategy guide on estimation approaches, see [Estimate Exposure Before Execution](/how-to/how-to-estimate-exposure-before-execution-practical-reservation-strategies-for-cycles).
## The unit: USD_MICROCENTS
Cycles uses **USD_MICROCENTS** as its primary currency unit:
```
1 USD_MICROCENT = 10⁻⁶ cents = 10⁻⁸ dollars
$1.00 = 100,000,000 microcents
$0.01 = 1,000,000 microcents
```
The formula for converting provider pricing to microcents:
```
microcents = (price_per_million_tokens / 1,000,000) × token_count × 100,000,000
```
Simplified:
```
microcents = price_per_million_tokens × token_count × 100
```
## Provider pricing reference
### OpenAI
| Model | Input (per 1M tokens) | Output (per 1M tokens) | Input (microcents/token) | Output (microcents/token) |
|---|---|---|---|---|
| gpt-5.6-sol | $5.00 | $30.00 | 500 | 3,000 |
| gpt-5.6-terra | $2.50 | $15.00 | 250 | 1,500 |
| gpt-5.6-luna | $1.00 | $6.00 | 100 | 600 |
| gpt-5 | $1.25 | $10.00 | 125 | 1,000 |
| gpt-5-mini | $0.25 | $2.00 | 25 | 200 |
| gpt-5-nano | $0.05 | $0.40 | 5 | 40 |
| gpt-4o | $2.50 | $10.00 | 250 | 1,000 |
| gpt-4o-mini | $0.15 | $0.60 | 15 | 60 |
| gpt-4.1 | $2.00 | $8.00 | 200 | 800 |
| gpt-4.1-mini | $0.40 | $1.60 | 40 | 160 |
| gpt-4.1-nano | $0.10 | $0.40 | 10 | 40 |
| o3 | $2.00 | $8.00 | 200 | 800 |
| o3-mini | $1.10 | $4.40 | 110 | 440 |
| o4-mini | $1.10 | $4.40 | 110 | 440 |
### Anthropic
| Model | Input (per 1M tokens) | Output (per 1M tokens) | Input (microcents/token) | Output (microcents/token) |
|---|---|---|---|---|
| Claude Opus 4.8 | $5.00 | $25.00 | 500 | 2,500 |
| Claude Sonnet 4.6 | $3.00 | $15.00 | 300 | 1,500 |
| Claude Haiku 4.5 | $1.00 | $5.00 | 100 | 500 |
### Google
| Model | Input (per 1M tokens) | Output (per 1M tokens) | Input (microcents/token) | Output (microcents/token) |
|---|---|---|---|---|
| Gemini 2.5 Pro | $1.25 | $10.00 | 125 | 1,000 |
| Gemini 2.5 Flash | $0.30 | $2.50 | 30 | 250 |
| Gemini 2.5 Flash-Lite | $0.10 | $0.40 | 10 | 40 |
### Groq on-demand
| Model | Input (per 1M tokens) | Output (per 1M tokens) | Input (microcents/token) | Output (microcents/token) |
|---|---|---|---|---|
| openai/gpt-oss-20b | $0.075 | $0.30 | 7.5 | 30 |
| openai/gpt-oss-120b | $0.15 | $0.60 | 15 | 60 |
| qwen/qwen3.6-27b | $0.60 | $3.00 | 60 | 300 |
> Open-model pricing and availability vary by host. The table above is specifically Groq's on-demand pricing, not a universal rate for those model families. Round the final reservation amount up when a per-token conversion is fractional. Self-hosted models have no provider token invoice, but still consume compute; use a unit that matches what you want to bound. See the [Groq integration guide](/how-to/integrating-cycles-with-groq) and [Ollama integration guide](/how-to/integrating-cycles-with-ollama).
::: info Note
Provider rates above were checked on July 24, 2026. The OpenAI table includes the current GPT-5.6 family plus selected older models that still appear in examples. GPT-5.6 cache reads are discounted, cache writes cost 1.25 times the uncached input rate, and requests above 272,000 input tokens use higher rates for the full request. Prices, caching rules, long-context tiers, and regional premiums change; check each provider's pricing page before deploying. The formulas remain the same.
:::
## Quick estimation formula
For a single LLM call:
```
estimate = (max_input_tokens × input_microcents) + (max_output_tokens × output_microcents)
```
Then add a safety buffer:
```
reservation_amount = estimate × 1.2 # 20% buffer
```
### Example: GPT-5.6 Luna call with 2,000 input tokens, 1,000 max output tokens
```
input_cost = 2,000 × 100 = 200,000 microcents
output_cost = 1,000 × 600 = 600,000 microcents
total = 800,000 microcents ($0.008)
with buffer = 960,000 microcents
```
### Example: Claude Sonnet 4.6 call with 4,000 input tokens, 2,000 max output tokens
```
input_cost = 4,000 × 300 = 1,200,000 microcents
output_cost = 2,000 × 1,500 = 3,000,000 microcents
total = 4,200,000 microcents ($0.042)
with buffer = 5,040,000 microcents
```
## Estimation helpers in code
::: code-group
```python [Python]
import math
# Simple cost estimator
def estimate_cost(input_tokens: int, max_output_tokens: int, model: str) -> int:
"""Return estimated cost in USD_MICROCENTS with 20% buffer."""
rates = {
"gpt-5.6-sol": (500, 3000),
"gpt-5.6-terra": (250, 1500),
"gpt-5.6-luna": (100, 600),
"gpt-4o": (250, 1000),
"gpt-4o-mini": (15, 60),
"gpt-4.1": (200, 800),
"gpt-4.1-mini": (40, 160),
"gpt-4.1-nano": (10, 40),
"claude-sonnet": (300, 1500),
"claude-haiku": (100, 500),
"gemini-2.5-pro": (125, 1000),
"gemini-2.5-flash":(30, 250),
"groq:gpt-oss-20b":(7.5, 30),
"groq:gpt-oss-120b":(15, 60),
"groq:qwen3.6-27b": (60, 300),
}
input_rate, output_rate = rates.get(model, (100, 600))
estimate = (input_tokens * input_rate) + (max_output_tokens * output_rate)
return math.ceil(estimate * 1.2)
# Usage with the @cycles decorator
@cycles(
estimate=lambda prompt, max_tokens=1000: estimate_cost(
len(prompt) // 4, max_tokens, "gpt-5.6-luna"
),
action_kind="llm.completion",
action_name="openai:gpt-5.6-luna",
)
def ask(prompt: str, max_tokens: int = 1000) -> str:
...
```
```typescript [TypeScript]
function estimateCost(inputTokens: number, maxOutputTokens: number, model: string): number {
const rates: Record = {
"gpt-5.6-sol": [500, 3000],
"gpt-5.6-terra": [250, 1500],
"gpt-5.6-luna": [100, 600],
"gpt-4o": [250, 1000],
"gpt-4o-mini": [15, 60],
"gpt-4.1": [200, 800],
"gpt-4.1-mini": [40, 160],
"gpt-4.1-nano": [10, 40],
"claude-sonnet": [300, 1500],
"claude-haiku": [100, 500],
"gemini-2.5-pro": [125, 1000],
"gemini-2.5-flash":[30, 250],
"groq:gpt-oss-20b":[7.5, 30],
"groq:gpt-oss-120b":[15, 60],
"groq:qwen3.6-27b": [60, 300],
};
const [inputRate, outputRate] = rates[model] ?? [100, 600];
const estimate = inputTokens * inputRate + maxOutputTokens * outputRate;
return Math.ceil(estimate * 1.2);
}
const ask = withCycles(
{
estimate: (prompt: string) => estimateCost(Math.ceil(prompt.length / 4), 1000, "gpt-5.6-luna"),
actionKind: "llm.completion",
actionName: "openai:gpt-5.6-luna",
},
async (prompt: string) => { ... },
);
```
:::
## Common reservation amounts
Quick reference for typical operations (including 20% buffer):
| Operation | Model | Typical Estimate (microcents) | Approx USD |
|---|---|---|---|
| Short chat reply (500 in / 200 out) | gpt-5.6-luna | 204,000 | $0.002 |
| Long chat reply (2,000 in / 1,000 out) | gpt-5.6-luna | 960,000 | $0.010 |
| Document summary (8,000 in / 2,000 out) | gpt-5.6-luna | 2,400,000 | $0.024 |
| Short chat reply (500 in / 200 out) | gpt-4o-mini | 23,400 | $0.0002 |
| Long chat reply (2,000 in / 1,000 out) | claude-sonnet | 2,520,000 | $0.025 |
| Code generation (4,000 in / 4,000 out) | claude-sonnet | 8,640,000 | $0.086 |
## When you don't know the exact token count
Use these rules of thumb:
- **1 token is roughly 4 characters** of English text (or ~0.75 words)
- For input: count the prompt characters and divide by 4
- For output: use the `max_tokens` parameter you're passing to the provider
- **Always round up** — over-reserving temporarily locks budget but releases the unused portion on commit
## Using TOKENS unit instead of USD_MICROCENTS
If you prefer to budget in tokens rather than dollars:
```python
@cycles(estimate=2000, unit="TOKENS", action_kind="llm.completion", action_name="gpt-5.6-luna")
def ask(prompt: str) -> str:
...
```
This is simpler but does not account for different costs across models. Use `TOKENS` when all your calls use the same model, or when you want model-agnostic budgets.
## Overage policies and estimation
Your estimation strategy should match your [overage policy](/protocol/commit-overage-policies-in-cycles-reject-allow-if-available-and-allow-with-overdraft):
| Policy | Estimation approach |
|---|---|
| **REJECT** | Reserve conservatively (use 120-150% buffer). Under-reserving causes commit failures. |
| **ALLOW_IF_AVAILABLE** | Reserve your best estimate. If actual exceeds reserved, the delta is deducted from remaining budget. |
| **ALLOW_WITH_OVERDRAFT** | Reserve normally. Overage is allowed up to the overdraft limit. Best for SLA-critical operations. |
## Next steps
- [Estimate Exposure Before Execution](/how-to/how-to-estimate-exposure-before-execution-practical-reservation-strategies-for-cycles) — detailed strategy guide for improving estimation over time
- [Understanding Units](/protocol/understanding-units-in-cycles-usd-microcents-tokens-credits-and-risk-points) — how USD_MICROCENTS, TOKENS, CREDITS, and RISK_POINTS work
- [Commit Overage Policies](/protocol/commit-overage-policies-in-cycles-reject-allow-if-available-and-allow-with-overdraft) — what happens when actual exceeds estimated
- [How Much Do AI Agents Actually Cost?](/blog/how-much-do-ai-agents-cost) — per-token pricing across providers with real-world cost scenarios
# Custom Field Resolvers in Cycles
The `CyclesFieldResolver` interface lets you resolve Subject fields dynamically at runtime in the Spring Boot Starter. This is useful when values like tenant, workspace, or agent depend on the current request context, user session, or database lookup.
::: info Python equivalent
In the Python client, Subject fields are resolved from decorator parameters, then from `CyclesConfig` defaults. For dynamic resolution, pass a `CyclesConfig` with fields set at initialization time, or pass subject fields directly to each `@cycles` decorator call. See the [Python Client Configuration Reference](/configuration/python-client-configuration-reference) for details.
:::
## The interface
```java
@FunctionalInterface
public interface CyclesFieldResolver {
String resolve();
}
```
A resolver returns a `String` value for its associated Subject field, or `null` if no value should be set.
## How resolution works
For each Subject field (tenant, workspace, app, workflow, agent, toolset), the starter resolves the value in this order:
1. **Annotation attribute** — if set on the `@Cycles` annotation, it wins
2. **Configuration property** — if set in `application.yml` (e.g., `cycles.tenant`)
3. **CyclesFieldResolver bean** — if a Spring bean named after the field exists
This means a resolver is the fallback. It is only called when the annotation and configuration do not provide a value.
::: tip Value already in a method argument?
Since 0.2.1, subject fields on `@Cycles` accept SpEL directly — `@Cycles(value = "1000", tenant = "#tenantId")` pulls the tenant from a method argument without a resolver bean. Resolvers are for values that come from outside the method signature (security context, database, thread-local state).
:::
## Creating a resolver
Register a Spring bean whose name matches the Subject field you want to resolve.
### Tenant resolver
```java
@Component("tenant")
public class TenantResolver implements CyclesFieldResolver {
@Autowired
private TenantContext tenantContext;
@Override
public String resolve() {
return tenantContext.getCurrentTenant();
}
}
```
### Workspace resolver
```java
@Component("workspace")
public class WorkspaceResolver implements CyclesFieldResolver {
@Autowired
private EnvironmentService environmentService;
@Override
public String resolve() {
return environmentService.getCurrentEnvironment();
}
}
```
### Agent resolver
```java
@Component("agent")
public class AgentResolver implements CyclesFieldResolver {
@Autowired
private AgentRegistry registry;
@Override
public String resolve() {
return registry.getCurrentAgentId();
}
}
```
## Supported field names
Register a bean with one of these names:
| Bean name | Subject field |
|---|---|
| `"tenant"` | `subject.tenant` |
| `"workspace"` | `subject.workspace` |
| `"app"` | `subject.app` |
| `"workflow"` | `subject.workflow` |
| `"agent"` | `subject.agent` |
| `"toolset"` | `subject.toolset` |
## Real-world example: multi-tenant SaaS
In a multi-tenant application, the tenant is typically extracted from the current request (JWT token, session, or request header):
```java
@Component("tenant")
public class RequestTenantResolver implements CyclesFieldResolver {
@Override
public String resolve() {
// Get tenant from Spring Security context
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof TenantAwareAuthentication tenantAuth) {
return tenantAuth.getTenantId();
}
return null;
}
}
```
Now every `@Cycles`-annotated method automatically uses the request's tenant without specifying it in the annotation:
```java
@Cycles("5000")
public String summarize(String text) {
// tenant is resolved automatically from the request context
return chatModel.call(text);
}
```
## Real-world example: database lookup
If the tenant or workspace comes from a database:
```java
@Component("tenant")
public class DatabaseTenantResolver implements CyclesFieldResolver {
@Autowired
private RepositoryAccessService repositoryService;
@Override
public String resolve() {
Optional tenant = repositoryService.findTenant();
return tenant.orElse(null);
}
}
```
## Resolver precedence in practice
Given this configuration:
```yaml
cycles:
tenant: default-tenant
workspace: production
```
And this resolver:
```java
@Component("tenant")
public class TenantResolver implements CyclesFieldResolver {
public String resolve() { return "resolved-tenant"; }
}
```
The effective values depend on the annotation:
```java
// Uses the annotation value: "explicit-tenant"
@Cycles(value = "1000", tenant = "explicit-tenant")
public void method1() { ... }
// No annotation value → uses the config value: "default-tenant".
// The resolver is never called because config already provides a value.
@Cycles("1000")
public void method2() { ... }
```
The resolver is only consulted when neither the annotation nor configuration provides a value. With the configuration above, `TenantResolver` never runs. Remove `cycles.tenant` from the configuration and `method2()` resolves to `"resolved-tenant"` from the resolver bean instead.
## Returning null
If a resolver returns `null`, that field is omitted from the Subject. The server will then derive it from context (e.g., the API key's tenant).
```java
@Component("workflow")
public class WorkflowResolver implements CyclesFieldResolver {
@Override
public String resolve() {
// Only set workflow if we're inside a workflow context
WorkflowContext ctx = WorkflowContext.current();
return ctx != null ? ctx.getWorkflowId() : null;
}
}
```
## Thread safety
Resolvers are called on the thread that invokes the `@Cycles`-annotated method. If your resolver reads from `ThreadLocal` state (like `SecurityContextHolder` or request-scoped beans), it will work correctly as long as the annotated method runs on the request thread.
If you use `@Async` or execute on a different thread, ensure the context is propagated.
## Testing resolvers
Test resolvers directly since they implement a simple interface:
```java
@Test
void testTenantResolution() {
TenantResolver resolver = new TenantResolver();
// Set up the context your resolver reads from
TenantContext.set("test-tenant");
assertEquals("test-tenant", resolver.resolve());
}
@Test
void testNullWhenNoContext() {
TenantResolver resolver = new TenantResolver();
TenantContext.clear();
assertNull(resolver.resolve());
}
```
## Summary
- Implement `CyclesFieldResolver` and register as a named Spring bean
- Bean name must match the Subject field: `tenant`, `workspace`, `app`, `workflow`, `agent`, or `toolset`
- Resolvers are the lowest-priority source (after annotation and config)
- Return `null` to omit a field
- Useful for multi-tenant SaaS, request-scoped context, and database lookups
## Working example in the demo app
The demo application includes a complete working field resolver:
- **`CyclesTenantResolver.java`** (`cycles-demo-client-java-spring/src/main/java/io/runcycles/demo/client/spring/resolvers/CyclesTenantResolver.java`) — Registered as `@Component("tenant")`, implements `CyclesFieldResolver`, and resolves the tenant dynamically via a repository service lookup. This is exactly the "database lookup" pattern described above.
The resolver is used automatically by all `@Cycles`-annotated methods in the demo when no tenant is specified in the annotation or `application.yml` configuration.
## Next steps
- [Python Client Configuration](/configuration/python-client-configuration-reference) — Python config properties and resolution order
- [Spring Client Configuration](/configuration/client-configuration-reference-for-cycles-spring-boot-starter) — Spring Boot config properties and resolution order
- [Getting Started with the Spring Boot Starter](/quickstart/getting-started-with-the-cycles-spring-boot-starter) — annotation usage
- [Testing with Cycles](/how-to/testing-with-cycles) — testing resolvers and annotations
# Integration Ecosystem
Cycles integrates with the tools, frameworks, and AI providers you already use. Whether you're building autonomous agents, adding runtime authority to an existing application, or exploring what's possible with controlled AI spending, there's an integration path for you.
## AI Model Providers
### OpenAI
Integrate Cycles with GPT-5.6 and other models available through the OpenAI API. Reserve budget per protected request and use standard subject fields for enforceable workflow or agent scopes.
- [OpenAI integration guide (Python)](/how-to/integrating-cycles-with-openai)
- [OpenAI integration guide (TypeScript)](/how-to/integrating-cycles-with-openai-typescript)
- [openai.com](https://openai.com)
### Anthropic
Use Cycles with Claude models to set spending limits on autonomous agent workflows powered by Anthropic's API. Available in both Python and TypeScript.
- [Anthropic integration guide (Python)](/how-to/integrating-cycles-with-anthropic)
- [Anthropic integration guide (TypeScript)](/how-to/integrating-cycles-with-anthropic-typescript)
- [anthropic.com](https://anthropic.com)
### Google Gemini
Add runtime authority to applications built on Google's Gemini family of models.
- [Gemini integration guide](/how-to/integrating-cycles-with-google-gemini)
- [ai.google.dev](https://ai.google.dev)
### AWS Bedrock
Cycles works with AWS Bedrock's multi-model platform, giving you budget control across any foundation model available through Bedrock.
- [AWS Bedrock integration guide](/how-to/integrating-cycles-with-aws-bedrock)
- [aws.amazon.com/bedrock](https://aws.amazon.com/bedrock)
### Ollama / Local LLMs
Budget control for local model runners — track GPU time and compute costs for self-hosted models. Works with Ollama, vLLM, text-generation-inference, and LocalAI.
- [Ollama integration guide](/how-to/integrating-cycles-with-ollama)
- [ollama.com](https://ollama.com)
### Groq
Budget governance for Groq's LPU-accelerated inference. Uses the OpenAI-compatible API with current Groq model IDs and pricing, plus an application-owned fallback pattern after a primary route's budget reservation is rejected.
- [Groq integration guide](/how-to/integrating-cycles-with-groq)
- [groq.com](https://groq.com)
## AI Frameworks & SDKs
### LangChain (Python)
[](https://pypi.org/project/langchain-runcycles/)
Build budget-aware LangChain agents in Python. The `langchain-runcycles` package ships three `AgentMiddleware` classes — `CyclesModelGate`, `CyclesToolGate`, and `CyclesFanOutGate` — that gate model calls, tool calls, and runaway agent loops in `create_agent` workflows. The parent `runcycles` SDK includes a lifecycle-managed callback recipe for bare runnables (chains and RAG), with heartbeat and durable settlement rather than an in-memory-only commit path.
- [langchain-runcycles on PyPI](https://pypi.org/project/langchain-runcycles/)
- [LangChain integration guide](/how-to/integrating-cycles-with-langchain)
- [Source on GitHub](https://github.com/runcycles/langchain-runcycles)
- [Python LangChain documentation](https://docs.langchain.com/oss/python/langchain/overview)
### LangChain.js
The same LangChain integration, purpose-built for JavaScript and TypeScript environments.
- [LangChain.js integration guide](/how-to/integrating-cycles-with-langchain-js)
- [JavaScript LangChain documentation](https://docs.langchain.com/oss/javascript/langchain/overview)
### LangGraph
Budget control for LangGraph stateful agent workflows. Use LangChain's callback handler inside graph nodes, or scope budgets per node with the `@cycles` decorator. Supports conditional routing based on remaining budget.
- [LangGraph integration guide](/how-to/integrating-cycles-with-langgraph)
- [langchain-ai.github.io/langgraph](https://langchain-ai.github.io/langgraph/)
### Vercel AI SDK
Add Cycles runtime authority to applications built with the Vercel AI SDK for seamless spending control in Next.js and other Vercel-deployed projects.
- [Vercel AI SDK integration guide](/how-to/integrating-cycles-with-vercel-ai-sdk)
- [ai-sdk.dev](https://ai-sdk.dev/)
### Spring AI
Integrate Cycles with Spring AI to bring runtime authority to Java and Kotlin AI applications. Two paths:
- **Auto-wired advisor** ([`cycles-spring-ai-starter`](https://github.com/runcycles/cycles-spring-ai-starter)) — zero-code gating of every `ChatClient.call()`. Recommended for pure Spring AI apps.
- **`@Cycles` annotation** ([`cycles-client-java-spring`](https://github.com/runcycles/cycles-spring-boot-starter)) — method-level gating with SpEL-driven estimates. Use for non-Spring-AI code paths.
See the [integration guide](/how-to/integrating-cycles-with-spring-ai) for the comparison + when to use each.
- [Spring AI integration guide](/how-to/integrating-cycles-with-spring-ai)
- [Spring AI strategic quickstart](/quickstart/how-to-add-hard-budget-limits-to-spring-ai-with-cycles)
- [spring.io/projects/spring-ai](https://spring.io/projects/spring-ai)
### LlamaIndex
Add budget governance to LlamaIndex RAG pipelines. Guard retrieval and generation stages separately for fine-grained cost control.
- [LlamaIndex integration guide](/how-to/integrating-cycles-with-llamaindex)
- [llamaindex.ai](https://www.llamaindex.ai)
### CrewAI
Budget control for CrewAI multi-agent workflows. Scope budgets per agent and per crew with hierarchical budget paths.
- [CrewAI integration guide](/how-to/integrating-cycles-with-crewai)
- [crewai.com](https://www.crewai.com)
### Pydantic AI
Guard Pydantic AI agent runs and tool calls with the `@cycles` decorator. Works with structured output and tool scoping.
- [Pydantic AI integration guide](/how-to/integrating-cycles-with-pydantic-ai)
- [Pydantic AI documentation](https://pydantic.dev/docs/ai/overview/)
### AnyAgent
Budget governance for AnyAgent's unified agent interface. A single callback covers all seven supported frameworks (OpenAI Agents, LangChain, LlamaIndex, Google, Agno, smolagents, TinyAgent) with no per-framework code.
- [AnyAgent integration guide](/how-to/integrating-cycles-with-anyagent)
- [mozilla-ai.github.io/any-agent](https://mozilla-ai.github.io/any-agent/)
### AutoGen
Budget governance for Microsoft AutoGen multi-agent workflows. Wrap the model client with Cycles reservations for per-call and per-agent cost control across teams, swarms, and graph flows.
- [AutoGen integration guide](/how-to/integrating-cycles-with-autogen)
- [microsoft.github.io/autogen](https://microsoft.github.io/autogen/)
## Web Frameworks
### Next.js
Add budget governance to Next.js applications with route-level budget guards, server actions, and client-side error handling. Works with any LLM provider.
- [Next.js integration guide](/how-to/integrating-cycles-with-nextjs)
- [nextjs.org](https://nextjs.org)
### Express.js
Add Cycles middleware to your Express.js API to enforce runtime authority on any route that triggers AI spending.
- [Express.js integration guide](/how-to/integrating-cycles-with-express)
- [expressjs.com](https://expressjs.com)
### Django
Add Cycles middleware to Django applications for budget-checked views, per-tenant isolation, and preflight budget guards.
- [Django integration guide](/how-to/integrating-cycles-with-django)
- [djangoproject.com](https://www.djangoproject.com)
### Flask
Add Cycles budget guards to Flask applications with error handlers, `before_request` hooks, and per-tenant isolation.
- [Flask integration guide](/how-to/integrating-cycles-with-flask)
- [flask.palletsprojects.com](https://flask.palletsprojects.com)
### FastAPI
Use the Cycles Python client with FastAPI for high-performance, budget-aware AI APIs.
- [FastAPI integration guide](/how-to/integrating-cycles-with-fastapi)
- [fastapi.tiangolo.com](https://fastapi.tiangolo.com)
## Agent Platforms
### MCP (Model Context Protocol)
Cycles provides an MCP server that exposes runtime authority as tools for any MCP-compatible client, including Claude Desktop, Claude Code, Cursor, and Windsurf.
- [MCP integration guide](/how-to/integrating-cycles-with-mcp)
- [modelcontextprotocol.io](https://modelcontextprotocol.io)
### OpenAI Agents SDK
[](https://pypi.org/project/runcycles-openai-agents/)
Add budget governance to OpenAI Agents SDK workflows. The plugin hooks into the SDK's `RunHooks` interface to enforce budgets on LLM calls and tool invocations, and emits best-effort zero-amount audit events for agent handoffs — with tool risk mapping and pre-run guardrails.
- [runcycles-openai-agents on PyPI](https://pypi.org/project/runcycles-openai-agents/)
- [OpenAI Agents integration guide](/how-to/integrating-cycles-with-openai-agents)
- [Source on GitHub](https://github.com/runcycles/cycles-openai-agents)
### OpenClaw
[](https://www.npmjs.com/package/@runcycles/openclaw-budget-guard)
Connect Cycles to OpenClaw for budget-controlled multi-agent orchestration.
- [@runcycles/openclaw-budget-guard on npm](https://www.npmjs.com/package/@runcycles/openclaw-budget-guard)
- [OpenClaw integration guide](/how-to/integrating-cycles-with-openclaw)
## Official SDKs
### Python Client
[](https://pypi.org/project/runcycles/)
The official Cycles Python client. Install from PyPI and start enforcing budgets in minutes.
- [runcycles on PyPI](https://pypi.org/project/runcycles/)
- [Python quickstart](/quickstart/getting-started-with-the-python-client)
### TypeScript Client
[](https://www.npmjs.com/package/runcycles)
The official Cycles TypeScript client for Node.js and browser environments.
- [runcycles on npm](https://www.npmjs.com/package/runcycles)
- [TypeScript quickstart](/quickstart/getting-started-with-the-typescript-client)
### Rust Client
[](https://crates.io/crates/runcycles)
The official Cycles Rust client (currently 0.3.2). Async-first, built for budget-aware agents and services in Rust.
- [runcycles on crates.io](https://crates.io/crates/runcycles)
- [Rust quickstart](/quickstart/getting-started-with-the-rust-client)
### AP2 Payment-Mandate Guard (Python)
[](https://pypi.org/project/runcycles-ap2/)
Runtime authority guard for AP2 (Agent Payments Protocol) — reserve, commit, and release around agent payment mandates. It deduplicates Cycles accounting and rejects divergent reuse of an `open_mandate_hash`; identical retries still require PSP idempotency or a separate consume-once claim to prevent duplicate charges. Works with Google's AP2 spec and any AP2-compatible SDK.
- [runcycles-ap2 on PyPI](https://pypi.org/project/runcycles-ap2/)
- [Source on GitHub](https://github.com/runcycles/cycles-ap2-python)
### MCP Server
[](https://www.npmjs.com/package/@runcycles/mcp-server)
The Cycles MCP server exposes runtime authority as tools for Claude Desktop, Claude Code, Cursor, and Windsurf.
- [@runcycles/mcp-server on npm](https://www.npmjs.com/package/@runcycles/mcp-server)
- [MCP quickstart](/quickstart/getting-started-with-the-mcp-server)
### Spring Boot Starter (generic `@Cycles` AOP)
[](https://central.sonatype.com/artifact/io.runcycles/cycles-client-java-spring)
Auto-configured Cycles integration for Spring Boot applications using the `@Cycles` annotation with SpEL-driven cost estimates. Available on Maven Central.
- [cycles-client-java-spring on Maven Central](https://central.sonatype.com/artifact/io.runcycles/cycles-client-java-spring)
- [Spring Boot quickstart](/quickstart/getting-started-with-the-cycles-spring-boot-starter)
### Spring AI Starter (advisor-based)
[](https://central.sonatype.com/artifact/io.runcycles/cycles-spring-ai-starter)
Spring AI-specific starter that auto-wires Cycles `CallAdvisor` + `StreamAdvisor` onto every `ChatClient`, gating non-streaming and streaming LLM invocations through Cycles without code changes at call sites. Also ships `CyclesToolGate` (opt-in per-tool gating), a pluggable `SubjectResolver` for per-request tenant attribution, and an `ObservationConvention` that emits Cycles attribution + `cycles.reservation_id` on chat-client traces. Companion to the generic Spring Boot starter — depend on this for Spring AI apps.
- [cycles-spring-ai-starter on Maven Central](https://central.sonatype.com/artifact/io.runcycles/cycles-spring-ai-starter)
- [Spring AI integration guide](/how-to/integrating-cycles-with-spring-ai)
## Protocol & Standards
### Cycles Protocol
The Cycles Protocol is an open specification for runtime authority in autonomous agent systems, licensed under Apache 2.0. Build your own implementation or contribute to the spec.
- [Cycles Protocol on GitHub](https://github.com/runcycles/cycles-protocol)
### OpenAPI Specification
A complete OpenAPI specification is available for the Cycles API, making it straightforward to generate clients in any language or integrate with API tooling.
- [Interactive API Reference](/api/)
## Community Tools
The Cycles ecosystem grows with every project that adopts runtime authority. If you've built a library, plugin, tool, or integration that works with Cycles, we want to hear about it.
Building something with Cycles? Add a [Built with Cycles badge](/community/badges) to your project and let the community know what you're working on.
# Cycles Budget Guard for Claude Code
The [Cycles MCP Server](/how-to/integrating-cycles-with-mcp) gives agents budget *tools* — but honoring a `DENY` inside the agent's tool loop is cooperative: nothing in MCP forces the model to reserve before acting. **Cycles Budget Guard for Claude Code** closes that gap by putting Cycles in the **tool dispatch path**: a `PreToolUse` hook reserves budget before each gated tool call, and a denial blocks the call at the harness layer. The model cannot skip that hook decision.
Repository: [runcycles/cycles-claude-plugin](https://github.com/runcycles/cycles-claude-plugin) (Apache-2.0, zero runtime dependencies). The hooks require Node.js 22 or newer.
## Install
```
/plugin marketplace add runcycles/cycles-claude-plugin
/plugin install cycles-budget-guard@runcycles
```
Then configure the environment Claude Code runs in:
```bash
export CYCLES_BASE_URL=https://your-cycles-server
export CYCLES_API_KEY=your-key
export CYCLES_DEFAULT_TENANT=acme # one subject field is required; tenant shown
export CYCLES_DEFAULT_APP=claude-code # optional, finer attribution
```
The protocol requires at least one standard subject field: `tenant`, `workspace`, `app`, `workflow`, `agent`, or `toolset`. It does not specifically require `tenant`; see [Understanding Tenants, Scopes, and Budgets](/how-to/understanding-tenants-scopes-and-budgets-in-cycles) when choosing the routing subject. Without a base URL or subject default, the plugin remains dormant. Once configured, an invalid value blocks calls and identifies the affected variable.
## How Claude Code budget enforcement works
1. **`PreToolUse`** reserves a flat per-call cost. The call is **blocked** on: a Cycles `DENY`; any authoritative protocol rejection (budget exhausted, frozen, or closed; debt; auth failure; invalid request); or any malformed response. Fail-open applies only to genuine outages (5xx, network errors, a four-second timeout). A fail-open call is unmetered because no reservation exists; `CYCLES_CC_FAIL_CLOSED=true` blocks that path.
2. **Tool-list caps are enforced at the gate.** `ALLOW_WITH_CAPS` tool allowlists and denylists block violating calls, with [allowlist precedence defined by the protocol](/protocol/caps-and-the-three-way-decision-model-in-cycles). Max-token, remaining-step, and cooldown caps are added to model context as guidance rather than mechanically enforced by the hook.
3. **`PostToolUse`** (success) durably changes the reservation record from `hold` to `commit`, then attempts settlement. If the reservation expired, was finalized, or is missing, the hook submits an idempotent usage event. This recovery path begins only after the successful outcome is durably recorded.
4. **`PostToolUseFailure`** attempts to release a `hold` for a call Claude Code reports as failed and retains a failed release for retry. A failed tool can still have partial side effects, so this hook outcome is not proof that no work occurred.
5. **`SessionEnd`** retries releases, commits, and usage events for the ending session. **`SessionStart`** replays commit and usage-event records across sessions with the same routing identity, but deliberately leaves unresolved `hold` records to their owning session or server-side TTL. State is scoped per OS user to a routing hash of (base URL, full subject, unit). Different routing identities cannot see one another's records; projects using the same tuple share a recovery scope.
## Budget Guard options
| Variable | Default | Meaning |
|---|---|---|
| `CYCLES_CC_UNIT` | `CREDITS` | Unit requested for each gated reservation |
| `CYCLES_CC_COST` | `1` | Flat amount requested for each gated reservation |
| `CYCLES_CC_SKIP_TOOLS` | `^(Read\|Glob\|Grep\|LS\|NotebookRead\|TodoWrite\|AskUserQuestion)$` | Configurable tools not gated by default. Set `^$` to gate every non-Cycles tool |
| `CYCLES_CC_FAIL_CLOSED` | `false` | `true` blocks calls when the Cycles server is unreachable |
| `CYCLES_CC_TTL_MS` | `1800000` (30 min) | Reservation TTL; must outlive permission prompts and long tool runs |
The Cycles budget tools themselves are never gated (exact-namespace recursion guard). The plugin configures the pinned companion Cycles MCP Server, `@runcycles/mcp-server@0.6.1`, which `npx` fetches as needed rather than the plugin vendoring it. The model can plan with `cycles_check_balance` and explicit reserves while the dispatch hook gates other configured tools.
## Verify enforcement
1. Use a test subject with an exhausted, frozen, or closed Cycles budget.
2. Ask Claude Code to run a gated action such as a `Bash` command.
3. Confirm the reservation is denied and the tool does not execute.
4. Run `/cycles-budget-guard:budget` to inspect the active budget scopes (which reflect your configured subject).
## Failure, retry, and privacy semantics
- **Identity and retries**: when Claude Code supplies a non-empty per-call `tool_use_id`, transport retries reuse the same reservation and distinct IDs remain distinct. Older inputs without `tool_use_id` use a hash of session, prompt, tool, and input; identical fallback calls can collide and undercount.
- **Integrity vs. availability**: a reserve response the plugin cannot interpret (unknown decision, missing reservation id, mistyped caps) is treated as an integrity failure and **denied** — only outages are eligible for fail-open. A malformed settlement response retains local state for retry; state is cleared only after `COMMITTED`, `RELEASED`, or `APPLIED` is confirmed.
- **Privacy**: Cycles hook requests include the configured API credential, subject identifiers, tool name, unit/amount, reservation TTL or ID and fixed settlement reasons as needed, plus opaque idempotency digests computed locally. Raw tool arguments, file contents, and prompts are not included in Cycles request bodies; Claude Code's model-provider traffic is separate. Policy: [runcycles.io/privacy](/privacy).
- **Platform**: Claude Code only. Cowork support is deliberately unclaimed until hook and environment behavior are verified there.
## Choose the Cycles MCP Server or Budget Guard
| Capability | Cycles MCP Server alone | Cycles Budget Guard for Claude Code |
|---|---|---|
| Balance, reservation, and metering tools | Available | Available through the pinned companion server |
| Pre-execution tool blocking | Cooperative only | Hook-enforced for gated tools |
| Host support | Any compatible MCP host | Claude Code only |
| Setup | Add server | Add plugin and environment configuration |
For other hosts, see the [security model](https://github.com/runcycles/cycles-mcp-server#security-model--enforcement-boundary), the [OpenClaw integration](/how-to/integrating-cycles-with-openclaw), and the guide to [choosing an integration pattern](/how-to/choosing-the-right-integration-pattern).
# Error Handling Patterns in Cycles Client Code
This guide covers practical patterns for handling Cycles errors in your application — both with the decorator/annotation and with the programmatic client.
For Python-specific patterns (exception hierarchy, FastAPI integration), see [Error Handling in Python](/how-to/error-handling-patterns-in-python).
For TypeScript-specific patterns (exception hierarchy, Express/Next.js integration), see [Error Handling in TypeScript](/how-to/error-handling-patterns-in-typescript).
For Rust-specific patterns (RAII guard safety, `Error` enum matching, Axum integration), see [Error Handling in Rust](/how-to/error-handling-patterns-in-rust).
## Protocol error structure
The Python, Java, and TypeScript clients all expose structured error information when the server returns a protocol-level error.
::: code-group
```python [Python]
from runcycles import CyclesProtocolError
# Available attributes:
e.status # HTTP status code (e.g. 409)
e.error_code # Machine-readable error code (e.g. "BUDGET_EXCEEDED")
e.reason_code # Reason code string
e.retry_after_ms # Suggested retry delay in ms (or None)
e.request_id # Server request ID
e.details # Additional error details dict
# Convenience checks:
e.is_budget_exceeded()
e.is_overdraft_limit_exceeded()
e.is_debt_outstanding()
e.is_reservation_expired()
e.is_reservation_finalized()
e.is_idempotency_mismatch()
e.is_unit_mismatch()
e.is_retryable()
```
```java [Java]
public class CyclesProtocolException extends RuntimeException {
ErrorCode getErrorCode(); // Machine-readable error code
String getReasonCode(); // String error code
int getHttpStatus(); // HTTP status from the server
Integer getRetryAfterMs(); // Suggested retry delay (nullable)
// Convenience checks
boolean isBudgetExceeded();
boolean isOverdraftLimitExceeded();
boolean isDebtOutstanding();
boolean isReservationExpired();
boolean isReservationFinalized();
boolean isIdempotencyMismatch();
boolean isUnitMismatch();
}
```
```typescript [TypeScript]
import { CyclesProtocolError } from "runcycles";
// Available properties:
e.status; // HTTP status code (e.g. 409)
e.errorCode; // Machine-readable error code (e.g. "BUDGET_EXCEEDED")
e.reasonCode; // Reason code string
e.retryAfterMs; // Suggested retry delay in ms (or undefined)
e.requestId; // Server request ID
e.details; // Additional error details object
// Convenience checks:
e.isBudgetExceeded();
e.isOverdraftLimitExceeded();
e.isDebtOutstanding();
e.isReservationExpired();
e.isReservationFinalized();
e.isIdempotencyMismatch();
e.isUnitMismatch();
e.isRetryable();
```
:::
## Handling DENY decisions
When a reservation is denied, the decorated function / annotated method does not execute. An exception is thrown instead.
::: code-group
```python [Python]
from runcycles import cycles, BudgetExceededError, CyclesProtocolError
@cycles(estimate=1000)
def summarize(text: str) -> str:
return call_llm(text)
try:
result = summarize(text)
except BudgetExceededError:
result = "Service temporarily unavailable due to budget limits."
except CyclesProtocolError as e:
if e.retry_after_ms:
schedule_retry(text, delay_ms=e.retry_after_ms)
result = f"Request queued. Retrying in {e.retry_after_ms}ms."
else:
raise
```
```java [Java]
try {
return llmService.summarize(text);
} catch (CyclesProtocolException e) {
if (e.isBudgetExceeded() && e.getRetryAfterMs() != null) {
scheduleRetry(text, e.getRetryAfterMs());
return "Request queued. Retrying in " + e.getRetryAfterMs() + "ms.";
}
if (e.isBudgetExceeded()) {
return fallbackSummary(text);
}
throw e;
}
```
```typescript [TypeScript]
import { withCycles, BudgetExceededError, CyclesProtocolError } from "runcycles";
const summarize = withCycles(
{ estimate: 1000, actionKind: "llm.completion", actionName: "gpt-4o", client },
async (text: string) => callLlm(text),
);
try {
result = await summarize(text);
} catch (err) {
if (err instanceof BudgetExceededError) {
result = "Service temporarily unavailable due to budget limits.";
} else if (err instanceof CyclesProtocolError && err.retryAfterMs) {
scheduleRetry(text, err.retryAfterMs);
result = `Request queued. Retrying in ${err.retryAfterMs}ms.`;
} else {
throw err;
}
}
```
:::
## Degradation patterns
::: code-group
```python [Python]
from runcycles import BudgetExceededError
try:
result = premium_service.analyze(data) # GPT-4o, high cost
except BudgetExceededError:
result = basic_service.analyze(data) # GPT-4o-mini, lower cost
```
```java [Java]
try {
return premiumService.analyze(data); // Uses GPT-4o, high cost
} catch (CyclesProtocolException e) {
if (e.isBudgetExceeded()) {
return basicService.analyze(data); // Uses GPT-4o-mini, lower cost
}
throw e;
}
```
```typescript [TypeScript]
import { BudgetExceededError } from "runcycles";
try {
result = await premiumService.analyze(data); // GPT-4o, high cost
} catch (err) {
if (err instanceof BudgetExceededError) {
result = await basicService.analyze(data); // GPT-4o-mini, lower cost
} else {
throw err;
}
}
```
:::
## Handling debt and overdraft errors
### DebtOutstandingError / DEBT_OUTSTANDING
A scope has unpaid debt and no overdraft limit configured. New reservations are blocked until the debt is resolved or an overdraft limit is set.
::: code-group
```python [Python]
from runcycles import DebtOutstandingError
try:
result = process(input_data)
except DebtOutstandingError:
logger.warning("Scope has outstanding debt. Notifying operator.")
alert_operator("Budget debt detected. Funding required.")
result = "Service paused pending budget review."
```
```java [Java]
try {
return service.process(input);
} catch (CyclesProtocolException e) {
if (e.isDebtOutstanding()) {
log.warn("Scope has outstanding debt. Notifying operator.");
alertOperator("Budget debt detected. Funding required.");
return "Service paused pending budget review.";
}
throw e;
}
```
```typescript [TypeScript]
import { DebtOutstandingError } from "runcycles";
try {
result = await process(inputData);
} catch (err) {
if (err instanceof DebtOutstandingError) {
console.warn("Scope has outstanding debt. Notifying operator.");
alertOperator("Budget debt detected. Funding required.");
result = "Service paused pending budget review.";
} else {
throw err;
}
}
```
:::
### OverdraftLimitExceededError / OVERDRAFT_LIMIT_EXCEEDED
The scope's debt has exceeded its overdraft limit.
::: code-group
```python [Python]
from runcycles import OverdraftLimitExceededError
try:
result = process(input_data)
except OverdraftLimitExceededError:
logger.error("Overdraft limit exceeded. Scope is blocked.")
result = "Budget limit reached. Please contact support."
```
```java [Java]
try {
return service.process(input);
} catch (CyclesProtocolException e) {
if (e.isOverdraftLimitExceeded()) {
log.error("Overdraft limit exceeded. Scope is blocked.");
return "Budget limit reached. Please contact support.";
}
throw e;
}
```
```typescript [TypeScript]
import { OverdraftLimitExceededError } from "runcycles";
try {
result = await process(inputData);
} catch (err) {
if (err instanceof OverdraftLimitExceededError) {
console.error("Overdraft limit exceeded. Scope is blocked.");
result = "Budget limit reached. Please contact support.";
} else {
throw err;
}
}
```
:::
## Handling expired reservations
If work outlives TTL plus grace, commit returns `RESERVATION_EXPIRED`. Current Python 0.5.2+, TypeScript 0.4.2+, Java/Spring 0.3.2+, and Rust 0.3.2+ lifecycle helpers recover known spend through `POST /v1/events`, reusing the original idempotency key. They persist the event-mode transition so a restart does not resume a commit that is already known to be impossible.
Do not create a new reservation after the work has completed, and do not release merely to restore budget. For low-level clients, construct a direct event from the original subject, action, actual amount, metrics, metadata, and key, and persist that recovery before sending it.
See [SDK Settlement Recovery and Durability](/protocol/sdk-settlement-recovery-and-durability) for the lifecycle-helper boundary and journal behavior.
## Catching all Cycles errors
::: code-group
```python [Python]
from runcycles import (
BudgetExceededError,
DebtOutstandingError,
OverdraftLimitExceededError,
CyclesProtocolError,
)
try:
result = guarded_func()
except BudgetExceededError:
result = fallback()
except DebtOutstandingError:
alert_operator("Debt outstanding")
result = "Service paused"
except OverdraftLimitExceededError:
result = "Budget limit reached"
except CyclesProtocolError as e:
if e.status == -1:
# Transport failure — the request never got an HTTP response
logger.error("Transport failure: %s", e)
else:
logger.error("Protocol error: %s (code=%s, status=%d)", e, e.error_code, e.status)
raise
# Commit-time settlement recovery is durable once actual usage is known.
# Monitor retained/quarantined journal records and recovery warnings.
```
```java [Java]
try {
return annotatedMethod();
} catch (CyclesProtocolException e) {
if (e.isBudgetExceeded()) {
return fallback();
} else if (e.isDebtOutstanding()) {
alertOperator("Debt outstanding");
return "Service paused";
} else if (e.isOverdraftLimitExceeded()) {
return "Budget limit reached";
} else {
log.error("Protocol error: code={}, status={}", e.getReasonCode(), e.getHttpStatus());
throw e;
}
}
```
```typescript [TypeScript]
import {
BudgetExceededError,
DebtOutstandingError,
OverdraftLimitExceededError,
ReservationExpiredError,
CyclesProtocolError,
} from "runcycles";
try {
result = await guardedFunc();
} catch (err) {
if (err instanceof BudgetExceededError) {
result = await fallback();
} else if (err instanceof DebtOutstandingError) {
alertOperator("Debt outstanding");
result = "Service paused";
} else if (err instanceof OverdraftLimitExceededError) {
result = "Budget limit reached";
} else if (err instanceof ReservationExpiredError) {
await recordAsEvent(data);
} else if (err instanceof CyclesProtocolError) {
if (err.status === -1) {
// Transport failure — the request never got an HTTP response
console.error(`Transport failure: ${err.message}`);
} else {
console.error(`Protocol error: ${err.message} (code=${err.errorCode}, status=${err.status})`);
}
throw err;
} else {
throw err;
}
}
```
:::
## Web framework error handlers
::: code-group
```python [FastAPI]
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from runcycles import CyclesProtocolError
app = FastAPI()
@app.exception_handler(CyclesProtocolError)
async def cycles_error_handler(request: Request, exc: CyclesProtocolError):
if exc.is_budget_exceeded():
retry_after = exc.retry_after_ms // 1000 if exc.retry_after_ms else 60
return JSONResponse(
status_code=429,
content={"error": "budget_exceeded", "message": "Budget limit reached."},
headers={"Retry-After": str(retry_after)},
)
if exc.is_debt_outstanding() or exc.is_overdraft_limit_exceeded():
return JSONResponse(
status_code=503,
content={"error": "service_unavailable", "message": "Service paused due to budget constraints."},
)
return JSONResponse(
status_code=500,
content={"error": "internal_error", "message": "An unexpected error occurred."},
)
```
```java [Spring]
@RestControllerAdvice
public class CyclesExceptionHandler {
@ExceptionHandler(CyclesProtocolException.class)
public ResponseEntity