Getting Started with the Rust Client
The runcycles Rust crate provides three levels of budget enforcement for any async Rust application:
with_cycles()— automatic reserve → execute → commit/release (like Python's@cyclesdecorator)ReservationGuard— RAII guard for manual control (streaming, multi-step workflows)CyclesClient— low-level programmatic API for full control
All three share the same lifecycle:
- Before the operation: evaluates the estimate, creates a reservation, and checks the decision
- While the operation runs: maintains the reservation with automatic heartbeat extensions
- After the operation returns: commits actual usage and releases any unused remainder
- 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 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
cargo add runcyclesOr add to Cargo.toml:
[dependencies]
runcycles = "0.2"
tokio = { version = "1", features = ["full"] }Requires Rust 1.88+. Dependencies (reqwest, serde, tokio) are installed automatically.
Configuration
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:
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-corplet config = CyclesConfig::from_env().expect("missing CYCLES_ env vars");
let client = CyclesClient::new(config);Automatic lifecycle with with_cycles()
The simplest way to add budget enforcement — wrap any async operation:
use runcycles::{CyclesClient, with_cycles, WithCyclesConfig, models::*};
let reply = with_cycles(
&client,
WithCyclesConfig::new(Amount::tokens(1000))
.action("llm.completion", "gpt-4o")
.subject(Subject { tenant: Some("acme-corp".into()), ..Default::default() }),
|ctx| async move {
// 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)))
},
).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:
|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 {
let max_tokens = caps.max_tokens.unwrap_or(1000);
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:
use runcycles::{CyclesClient, models::*};
// 1. Reserve
let guard = client.reserve(
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)
guard.commit(
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 ERRORGuard 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:
use runcycles::{CyclesClient, models::*};
// Create reservation
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::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,
&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
let resp = client.decide(
&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 DenyQuerying balances
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)
let resp = client.create_event(
&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()— returnsErr(Error::BudgetExceeded)on DENY. Closure never runs.ReservationGuard—client.reserve()returnsErr(Error::BudgetExceeded)on DENY.- Low-level —
resp.decisioncan be checked directly.
use runcycles::Error;
match client.reserve(/* ... */).await {
Ok(guard) => {
// ALLOW or ALLOW_WITH_CAPS — proceed
guard.commit(/* ... */).await?;
}
Err(Error::BudgetExceeded { message, retry_after, .. }) => {
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()
use runcycles::{CyclesClient, with_cycles, WithCyclesConfig, models::*};
let client = CyclesClient::builder("cyc_live_...", "http://localhost:7878")
.tenant("acme-corp")
.build();
let result = with_cycles(
&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
let resp = client.get_balances(&BalanceParams {
tenant: Some("acme-corp".into()),
..Default::default()
}).await?;
println!("{:?}", resp.balances);3. Try a dry run
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)
.build()
).await?;
println!("Decision: {:?}", resp.decision);
// Check balances — they haven't changed4. Use the RAII guard with caps
let guard = client.reserve(/* ... */).await?;
if guard.is_capped() {
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
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, .. }) => {
println!("Budget exhausted: {message} — using fallback");
}
Err(e) => println!("Error: {e}"),
}Lifecycle summary
For each with_cycles() call or ReservationGuard:
- Estimate is provided via
Amount - Reservation is created on the Cycles server
- Decision is checked (ALLOW / ALLOW_WITH_CAPS / DENY)
- If DENY:
Error::BudgetExceededis returned, operation does not run - Heartbeat extension is scheduled (background tokio task at TTL/2)
- Operation executes
- On success: commit is sent with actual amount and optional metrics
- On error: reservation is released to return budget
- Heartbeat is cancelled
- If guard is dropped without commit/release: best-effort release via
tokio::spawn
Next steps
- Error Handling Patterns — general error handling patterns
- API Reference — interactive endpoint documentation
- How Reserve/Commit Works — the core protocol lifecycle