Getting Started with the Python Client
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:
- Before the function runs: evaluates the estimate, creates a reservation, and checks the decision
- While the function runs: maintains the reservation with automatic heartbeat extensions
- After the function returns: commits actual usage and releases any unused remainder
- 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.
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 first.
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:
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. For rotation and lifecycle details, see API Key Management.
Verify your server is running
Before writing any code, confirm the Cycles Server is reachable:
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.
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 withcyc_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
pip install runcyclesRequires Python 3.10+. Dependencies (httpx, pydantic >= 2.0) are installed automatically.
Configuration
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:
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-corpconfig = CyclesConfig.from_env()The @cycles decorator
The simplest usage — wrap a function with a fixed estimate:
from runcycles import CyclesClient, cycles, set_default_client
client = CyclesClient(config)
set_default_client(client)
@cycles(estimate=1000)
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:
@cycles(estimate=lambda text, max_tokens: max_tokens * 10)
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:
@cycles(
estimate=5000,
actual=lambda result: len(result) * 5,
)
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:
@cycles(
estimate=1000,
workspace=lambda req, workspace_id: workspace_id,
action_kind=lambda req, *_: f"llm.{req.provider}",
action_name=lambda req, *_: req.model,
)
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():
from runcycles import cycles, get_cycles_context, CyclesMetrics
@cycles(estimate=1000)
def process(text: str) -> str:
ctx = get_cycles_context()
# 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)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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.
CyclesProtocolErroris raised, withreason_codeset. A specific subclass (e.g.BudgetExceededError) is raised only when the server returns a matching HTTP error code; a 200 response withdecision=DENYraises the plainCyclesProtocolError.
from runcycles import BudgetExceededError, CyclesProtocolError
try:
result = summarize("Hello")
except BudgetExceededError:
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:
from runcycles import AsyncCyclesClient, cycles, set_default_client
async_client = AsyncCyclesClient(config)
set_default_client(async_client)
@cycles(estimate=1000)
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:
from runcycles import (
CyclesClient, ReservationCreateRequest, CommitRequest, ReleaseRequest,
Subject, Action, Amount, Unit, CyclesMetrics,
)
with CyclesClient(config) as client:
# 1. Reserve
response = client.create_reservation(ReservationCreateRequest(
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(
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",
))
raise2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
Preflight decision check
from runcycles import DecisionRequest
response = client.decide(DecisionRequest(
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
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)
from runcycles import EventCreateRequest
response = client.create_event(EventCreateRequest(
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
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)
def hello(name: str) -> str:
return f"Hello, {name}!"
result = hello("world")
print(result)2. Check your balance
response = client.get_balances(tenant="acme-corp")
print(response.body)3. Try a dry run
@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 changed4. Use dynamic estimates with metrics
from runcycles import get_cycles_context, CyclesMetrics
@cycles(
estimate=lambda prompt, max_tokens: max_tokens * 10,
actual=lambda result: len(result) * 5,
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)2
3
4
5
6
7
8
9
10
11
12
13
14
5. Handle denials gracefully
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:
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:
@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 500This 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:
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 totalLifecycle summary
For each @cycles-decorated function call:
- Estimate is evaluated (callable or fixed value)
- Reservation is created on the Cycles server
- Decision is checked (ALLOW / ALLOW_WITH_CAPS / DENY)
- If DENY: exception is raised, function does not run
- Heartbeat extension is scheduled (background thread; asyncio task for async functions)
- Function executes
- Actual usage is evaluated (callable, fixed value, or estimate)
- Commit is sent with actual amount and optional metrics
- Heartbeat is cancelled
- If function raised: reservation is released instead of committed
Next steps
- Integrating with OpenAI Agents SDK — budget governance for multi-agent workflows
- Error Handling in Python — Python-specific exception hierarchy and patterns
- Error Handling Patterns — general error handling patterns
- API Reference — interactive endpoint documentation
- Using the Client Programmatically — programmatic client reference