A Spring Boot starter that integrates with the Cycles Budget Authority — a deterministic spend governance protocol for agent runtimes.
Cycles enforces budget reservations around guarded method executions using a reserve / execute / commit lifecycle. If the budget is exceeded, execution is denied before the guarded code runs.
<dependency>
<groupId>io.runcycles</groupId>
<artifactId>cycles-client-java-spring</artifactId>
<version>0.2.0</version>
</dependency>Requires Java 21+ and Spring Boot 3.3+. Designed for blocking Spring MVC workloads — not compatible with reactive WebFlux pipelines (see Threading Model).
Add the following to your project's application.yml:
cycles:
api-key: your-api-key
base-url: http://localhost:7878
tenant: my-tenant
workspace: development
app: my-appNeed an API key? API keys are created via the Cycles Admin Server (port 7979). See the deployment guide to create one, or run:
curl -s -X POST http://localhost:7979/v1/admin/api-keys \ -H "Content-Type: application/json" \ -H "X-Admin-API-Key: admin-bootstrap-key" \ -d '{"tenant_id":"acme-corp","name":"dev-key","permissions":["reservations:create","reservations:commit","reservations:release","reservations:extend","reservations:list","balances:read","decide","events:create"]}' | jq -r '.key_secret'The key (e.g.
cyc_live_abc123...) is shown only once — save it immediately. For key rotation and lifecycle details, see API Key Management.
@Service
public class LlmService {
// Minimal — just the estimate expression
@Cycles("#tokens * 10")
public String generateText(String prompt, int tokens) {
return callProvider(prompt, tokens);
}
}That's it. The aspect will automatically:
- Reserve budget before the method runs (using the estimated amount)
- Execute your method if the reservation is allowed
- Commit the actual usage after the method completes (defaults to estimated amount)
- Release the reservation if the method throws an exception
Action is auto-derived: actionKind = class name (LlmService), actionName = method name (generateText). Override when you need explicit control:
@Cycles(value = "#tokens * 10",
actionKind = "llm.completion",
actionName = "gpt-4",
actual = "#result.length() * 5")
public String generateText(String prompt, int tokens) { ... }┌─────────────────────────────────────────────────────────────────┐
│ @Cycles method invocation │
│ │
│ 1. Evaluate estimate expression → Amount(unit, amount) │
│ 2. POST /v1/reservations │
│ ├─ 409 (BUDGET_EXCEEDED / OVERDRAFT / DEBT) │
│ │ → throw CyclesProtocolException (method never runs) │
│ ├─ 200 ALLOW → reservation created, continue │
│ └─ 200 ALLOW_WITH_CAPS → Caps available via context │
│ 3. Start heartbeat (POST .../extend at ttlMs/2 intervals) │
│ 4. Execute the guarded method │
│ ├─ Success → evaluate actual expression │
│ │ POST /v1/reservations/{reservation_id}/commit │
│ │ (retries on transient failure) │
│ └─ Failure → POST /v1/reservations/{reservation_id}/release │
│ 5. Cancel heartbeat, clear CyclesContextHolder │
│ │
└─────────────────────────────────────────────────────────────────┘
| Scenario | Outcome | Detail |
|---|---|---|
| Reservation denied | Neither | CyclesProtocolException thrown; method never executes. Error may be BUDGET_EXCEEDED, OVERDRAFT_LIMIT_EXCEEDED, or DEBT_OUTSTANDING |
dryRun = true, any decision |
Neither | Returns DryRunResult or throws; no real reservation created |
| Method returns successfully | Commit | Actual amount charged; unused remainder auto-released |
| Method throws any exception | Release | Full reserved amount returned to budget; exception re-thrown |
| Commit fails (5xx / network) | Retry | Exponential backoff; see cycles.retry.* config |
| Commit fails (non-retryable 4xx) | Release | Reservation released after non-retryable client error |
| Commit gets RESERVATION_EXPIRED | Neither | Server already reclaimed budget on TTL expiry |
| Commit gets RESERVATION_FINALIZED | Neither | Already committed or released (idempotent replay) |
| Commit gets IDEMPOTENCY_MISMATCH | Neither | Previous commit already processed; no release attempted |
All exceptions from the guarded method trigger release — no distinction between checked and unchecked exceptions.
See How Reserve-Commit Works for the full protocol-level explanation.
The Cycles server returns one of three decisions on reservation:
| Decision | HTTP | Meaning |
|---|---|---|
ALLOW |
200 | Budget available, reservation created |
ALLOW_WITH_CAPS |
200 | Budget available with soft constraints (e.g., reduced token limits) |
DENY |
409 | Insufficient budget — expressed as an HTTP 409 error with an ErrorCode |
Per the spec, budget denials for non-dry-run reservations are expressed as HTTP 409 responses (not a 200 with decision=DENY). The starter throws a CyclesProtocolException for all non-2xx responses.
When ALLOW_WITH_CAPS is returned, the Caps object is available via CyclesContextHolder inside your method (see Accessing Caps).
All properties are configured in your project's application.yml (or application.properties).
cycles:
api-key: ${CYCLES_API_KEY} # X-Cycles-API-Key header (required)
base-url: http://localhost:7878 # Cycles server URL (required)
# Default subject fields (applied to all @Cycles methods unless overridden per-annotation)
tenant: acme-corp
workspace: development
app: my-app
# workflow, agent, toolset — omit if not needed globally
# HTTP client settings
http:
connect-timeout: 2s
read-timeout: 5s
# Commit retry settings (exponential backoff)
retry:
enabled: true
max-attempts: 5
initial-delay: 500ms
multiplier: 2.0
max-delay: 30s| Parameter | Required | Default | Description |
|---|---|---|---|
value / estimate |
Yes (one) | — | SpEL expression for estimated cost. Synonyms — use either one. value enables @Cycles("1000") shorthand; estimate reads naturally with actual. |
actionKind |
No | class name | Action category (e.g., llm.completion, tool.search) |
actionName |
No | method name | Action identifier (e.g., gpt-4, web.search) |
actual |
No | "" |
SpEL expression for actual cost (evaluated after method returns) |
actionTags |
No | {} |
Policy tags (e.g., {"prod", "customer-facing"}) |
useEstimateIfActualNotProvided |
No | true |
Use estimate as actual when actual is blank |
unit |
No | USD_MICROCENTS |
Cost unit: USD_MICROCENTS, TOKENS, CREDITS, RISK_POINTS |
ttlMs |
No | 60000 |
Reservation TTL in milliseconds (1,000–86,400,000) |
gracePeriodMs |
No | -1 (server default) |
Grace period for late commits (0–60,000 ms, server default: 5,000) |
overagePolicy |
No | ALLOW_IF_AVAILABLE |
REJECT, ALLOW_IF_AVAILABLE, or ALLOW_WITH_OVERDRAFT |
dryRun |
No | false |
Shadow-mode: server evaluates without persisting; method does NOT execute |
dimensions |
No | {} |
Custom Subject dimensions as "key=value" pairs |
tenant |
No | "" |
Override tenant (falls back to config, then resolver) |
workspace |
No | "" |
Override workspace |
app |
No | "" |
Override app |
workflow |
No | "" |
Override workflow |
agent |
No | "" |
Override agent |
toolset |
No | "" |
Override toolset |
Any subject field set on @Cycles overrides the config default for that method call. This changes which budget scope the reservation targets.
// Given config: tenant=acme-corp, workspace=development, app=my-app
// Uses ALL config defaults.
// Budget scope: tenant:acme-corp/workspace:development/app:my-app
@Cycles("1000")
public String defaultScope(String input) { ... }
// Overrides workspace → targets the STAGING budget, not development.
// Budget scope: tenant:acme-corp/workspace:staging/app:my-app
@Cycles(value = "1000", workspace = "staging")
public String stagingScope(String input) { ... }
// Overrides workspace AND app → targets production billing budget.
// Budget scope: tenant:acme-corp/workspace:production/app:billing-service
@Cycles(value = "#amount", workspace = "production", app = "billing-service")
public String productionBilling(int amount) { ... }Budget scopes are independent.
tenant:acme-corp/workspace:developmentandtenant:acme-corp/workspace:staginghave separate budgets. A reservation must pass budget checks at all affected scope levels in the hierarchy (e.g., bothtenant:acme-corpandtenant:acme-corp/workspace:staging).
Estimate and actual expressions are evaluated as SpEL with these variables available:
| Variable | Description |
|---|---|
#p0, #p1, ... |
Method parameters by index |
#paramName |
Method parameters by name (requires -parameters compiler flag) |
#result |
Method return value (only available in actual) |
#args |
All method arguments as an array |
#target |
The target object (the bean instance) |
Examples:
// Minimal — fixed estimate, estimate used as actual
@Cycles("1000")
// Estimate from parameter
@Cycles("#tokens * 10")
// With explicit actual
@Cycles(value = "#p1 * 10", actual = "#result.length() * 5")When the server returns ALLOW_WITH_CAPS, you can read the constraints inside your guarded method:
@Cycles(value = "#tokens * 10",
actual = "#result.length() * 5",
actionKind = "llm.completion",
actionName = "gpt-4")
public String generate(String prompt, int tokens) {
CyclesReservationContext ctx = CyclesContextHolder.get();
if (ctx.hasCaps()) {
Caps caps = ctx.getCaps();
// Reduce tokens if capped
if (caps.getMaxTokens() != null) {
tokens = Math.min(tokens, caps.getMaxTokens());
}
// Check if a tool is allowed
if (!caps.isToolAllowed("web_search")) {
// skip web search
}
}
return callLlm(prompt, tokens);
}Available Caps fields: maxTokens, maxStepsRemaining, toolAllowlist, toolDenylist, cooldownMs.
When a reservation is denied or a protocol error occurs, a CyclesProtocolException is thrown:
try {
llmService.generateText(prompt, tokens);
} catch (CyclesProtocolException e) {
ErrorCode code = e.getErrorCode();
if (e.isBudgetExceeded()) {
// Budget exhausted — show user a friendly message
} else if (e.isOverdraftLimitExceeded()) {
// Debt limit reached
}
// Also available:
// e.getHttpStatus()
// e.getReasonCode()
// e.getMessage()
}Error codes from the protocol:
| ErrorCode | HTTP | Meaning |
|---|---|---|
INVALID_REQUEST |
400 | Malformed request (missing required fields, invalid values) |
UNAUTHORIZED |
401 | Invalid or missing API key |
FORBIDDEN |
403 | Tenant mismatch (subject.tenant vs effective tenant) |
NOT_FOUND |
404 | Reservation does not exist |
BUDGET_EXCEEDED |
409 | Insufficient budget for reservation or commit |
OVERDRAFT_LIMIT_EXCEEDED |
409 | Debt exceeds overdraft limit, or scope is over-limit |
DEBT_OUTSTANDING |
409 | Outstanding debt blocks new reservations |
RESERVATION_FINALIZED |
409 | Reservation already committed or released |
IDEMPOTENCY_MISMATCH |
409 | Same idempotency key with different payload |
UNIT_MISMATCH |
400 | Commit unit differs from reservation unit |
RESERVATION_EXPIRED |
410 | Reservation TTL + grace period elapsed |
INTERNAL_ERROR |
500 | Server error |
Subject fields (tenant, workspace, app, workflow, agent, toolset) are resolved in order:
- Annotation value —
@Cycles(value = "1000", tenant = "my-tenant") - Configuration —
cycles.tenant=my-tenantinapplication.yml - Dynamic resolver — a Spring bean implementing
CyclesFieldResolver
Register a bean named after the field to resolve it dynamically at runtime:
@Component("tenant")
public class TenantResolver implements CyclesFieldResolver {
@Autowired
private TenantService tenantService;
@Override
public String resolve() {
return tenantService.getCurrentTenant();
}
}For long-running methods, the starter automatically extends the reservation TTL via the /v1/reservations/{reservation_id}/extend endpoint. The heartbeat fires at ttlMs / 2 intervals to prevent the reservation from expiring while the method is still executing.
No configuration needed — it activates automatically when the server returns an expires_at_ms in the reservation response.
If a commit fails due to a transient error (network failure or 5xx), the starter automatically retries with exponential backoff using a background thread. Configure via:
cycles:
retry:
enabled: true # default: true
max-attempts: 5 # default: 5
initial-delay: 500ms # default: 500ms
multiplier: 2.0 # default: 2.0
max-delay: 30s # default: 30sUse dryRun = true to evaluate a reservation without persisting it or locking budget. The guarded method will not execute — the aspect returns a DryRunResult immediately after the server responds.
@Cycles(value = "#tokens * 10", dryRun = true)
public String checkBudget(String prompt, int tokens) {
// This method body never executes in dry_run mode.
// The aspect returns a DryRunResult after the server evaluates.
return callLlm(prompt, tokens);
}If the server returns decision=DENY, a CyclesProtocolException is thrown (consistent with non-dry-run behavior), allowing callers to use dry-run as a programmatic budget availability check. If the decision is ALLOW or ALLOW_WITH_CAPS, the aspect returns a DryRunResult and the method does not execute.
The DryRunResult contains the full server evaluation:
Object result = myService.checkBudget(prompt, tokens);
if (result instanceof DryRunResult dryRun) {
Decision decision = dryRun.getDecision(); // ALLOW or ALLOW_WITH_CAPS
Caps caps = dryRun.getCaps(); // soft constraints (if any)
List<String> scopes = dryRun.getAffectedScopes();
String scopePath = dryRun.getScopePath();
Amount reserved = dryRun.getReserved();
List<Balance> balances = dryRun.getBalances(); // current balances (if returned)
}The starter automatically includes latency_ms (method execution time) in every commit. You can also set additional metrics inside your guarded method:
@Cycles(value = "#tokens * 10",
actual = "#result.length() * 5",
actionKind = "llm.completion",
actionName = "gpt-4")
public String generate(String prompt, int tokens) {
CyclesReservationContext ctx = CyclesContextHolder.get();
LlmResponse response = callLlm(prompt, tokens);
// Report token counts and model version
CyclesMetrics metrics = new CyclesMetrics();
metrics.setTokensInput(response.getInputTokens());
metrics.setTokensOutput(response.getOutputTokens());
metrics.setModelVersion(response.getModelVersion());
metrics.putCustom("cache_hit", response.isCacheHit());
ctx.setMetrics(metrics);
return response.getText();
}Attach arbitrary key-value metadata to the commit request for audit/debugging:
@Cycles(...)
public String process(String input) {
CyclesReservationContext ctx = CyclesContextHolder.get();
ctx.setCommitMetadata(Map.of("source", "batch-job", "batch_id", batchId));
return doWork(input);
}Attach custom dimensions to the Subject for enterprise taxonomies:
@Cycles(value = "1000",
dimensions = {"cost_center=engineering", "project=alpha"})
public String generate(String prompt) { ... }The CyclesClient interface exposes all optional protocol endpoints for programmatic use:
@Autowired
private CyclesClient cyclesClient;
// Preflight decision check (no reservation created)
CyclesResponse<Map<String,Object>> decision = cyclesClient.decide(decideBody);
// List reservations with filters
CyclesResponse<Map<String,Object>> list = cyclesClient.listReservations(
Map.of("status", "ACTIVE", "app", "my-app"));
// Get reservation detail
CyclesResponse<Map<String,Object>> detail = cyclesClient.getReservation(reservationId);
// Query balances
CyclesResponse<Map<String,Object>> balances = cyclesClient.getBalances(
Map.of("tenant", "my-tenant", "workspace", "production"));
// Post-only accounting event (no reservation)
CyclesResponse<Map<String,Object>> event = cyclesClient.createEvent(eventBody);Use CyclesRequestBuilderService to build request bodies for decide and event endpoints.
All beans are created with @ConditionalOnMissingBean, so you can override any component:
@Bean
public CyclesClient cyclesClient() {
// Custom HTTP client implementation
return new MyCyclesClient();
}
@Bean(name = "cyclesWebClient")
public WebClient cyclesWebClient() {
// Custom WebClient with additional headers, interceptors, etc.
return WebClient.builder()
.baseUrl("https://cycles.example.com")
.defaultHeader("X-Cycles-API-Key", "my-key")
.build();
}
@Bean
public CommitRetryEngine retryEngine() {
// Custom retry strategy (e.g., persistent queue)
return new MyPersistentRetryEngine();
}cycles-spring-boot-starter/
├── cycles-client-java-spring/ # The starter library
│ └── src/main/java/io/runcycles/client/java/spring/
│ ├── annotation/ # @Cycles annotation
│ ├── aspect/ # CyclesAspect (AOP interceptor)
│ ├── autoconfigure/ # Spring Boot auto-configuration
│ ├── client/ # CyclesClient interface & HTTP impl
│ ├── config/ # CyclesProperties
│ ├── context/ # CyclesContextHolder, request builders
│ ├── evaluation/ # SpEL evaluator, field resolvers
│ ├── model/ # Decision, Caps, ErrorCode, exceptions
│ ├── retry/ # CommitRetryEngine
│ └── util/ # Constants, validation
└── cycles-demo-client-java-spring/ # Demo application
└── src/main/java/io/runcycles/demo/client/spring/
├── controller/
│ ├── LlmController.java # LLM endpoints with error handling
│ └── DemoController.java # Central demo REST API (/api/demo/*)
├── service/
│ ├── LlmService.java # @Cycles with context, metrics, metadata
│ ├── AnnotationShowcaseService.java # Annotation variations (units, TTL, etc.)
│ ├── ProgrammaticClientService.java # Direct CyclesClient usage
│ └── EventService.java # Standalone events (direct debit)
├── resolvers/
│ └── CyclesTenantResolver.java # Dynamic tenant via CyclesFieldResolver
├── error/
│ └── CyclesExceptionHandler.java # Global error handler
└── resources/
└── application.yml # Demo configuration
The demo app at cycles-demo-client-java-spring/ showcases every major feature of the starter.
Prerequisites: You need a running Cycles stack with a tenant, API key, and budget. Follow the deployment guide to set up the acme-corp tenant used by the demo. Then:
cd cycles-demo-client-java-spring
export CYCLES_API_KEY=cyc_live_... # paste the key from the deployment guide
mvn spring-boot:runStart with the simplest endpoint:
curl -X POST http://localhost:7955/api/demo/annotation/minimal?input=helloHit GET http://localhost:7955/api/demo/index for a full listing of all endpoints with copy-paste curl commands.
Key demo scenarios:
/api/llm/*—@Cyclesannotation withCyclesContextHolder,CyclesMetrics, andcommitMetadata/api/demo/annotation/*— Annotation variations: per-annotation budget targeting (workspace/appoverride),unit=TOKENS,unit=CREDITS,overagePolicy,ttlMs/gracePeriodMs,dryRun,dimensions,workflow/agent/api/demo/client/*— ProgrammaticCyclesClientusage: reserve/commit, reserve/release, preflightdecide(),getBalances(),listReservations()/api/demo/events/*— Standalone events viacreateEvent()(direct debit without reservation)
This starter implements the Cycles Protocol v0 (v0.1.23).
| Feature | Status | Notes |
|---|---|---|
POST /v1/reservations (create) |
Implemented | Core — via @Cycles annotation and CyclesClient |
POST /v1/reservations/{reservation_id}/commit |
Implemented | Core — automatic after guarded method returns |
POST /v1/reservations/{reservation_id}/release |
Implemented | Core — automatic on method failure |
POST /v1/reservations/{reservation_id}/extend |
Implemented | Core — automatic heartbeat |
POST /v1/decide |
Implemented | Programmatic via CyclesClient.decide() |
GET /v1/reservations |
Implemented | Programmatic via CyclesClient.listReservations() |
GET /v1/reservations/{reservation_id} |
Implemented | Programmatic via CyclesClient.getReservation() |
GET /v1/balances |
Implemented | Programmatic via CyclesClient.getBalances() |
POST /v1/events |
Implemented | Programmatic via CyclesClient.createEvent() |
dry_run on reservation |
Implemented | Via @Cycles(dryRun = true) — method does not execute |
metrics on commit |
Implemented | Auto latency_ms; user sets via CyclesContextHolder |
metadata on requests |
Implemented | User sets commit metadata via CyclesContextHolder |
X-Idempotency-Key header |
Implemented | Sent automatically on all POST requests |
Subject.dimensions |
Implemented | Via @Cycles(dimensions = {"key=value"}) |
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.
// 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
}
}Move the @Cycles-annotated method into its own @Service and inject it:
@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);
}
}If extracting a bean is impractical, inject the proxy of your own class using @Lazy:
@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;
}
}Startup warning: The starter logs a
WARNat startup when it detects a bean where some methods have@Cyclesand others do not, since this pattern is susceptible to self-invocation issues. The warning is informational — it does not block startup.
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, over-reserving.
- Protocol design: The Cycles Protocol v0 has no concept of parent/child reservations. Each reservation is independent and atomic.
// 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:
@Service
public class Orchestrator {
@Autowired private LlmService llmService;
@Cycles("#tokens * 10")
public String orchestrate(int tokens) {
return llmService.generate("hello", tokens); // works — no @Cycles on inner method
}
}
@Service
public class LlmService {
// No @Cycles here — called from within an already-guarded operation
public String generate(String prompt, int tokens) { ... }
}CyclesContextHolder uses ThreadLocal to propagate reservation context to the guarded method. This works correctly with blocking Spring MVC but does not work with reactive WebFlux pipelines. Context will not propagate across reactive operator boundaries (e.g. Mono.flatMap, Flux.map), and calling CyclesContextHolder.get() from a scheduler thread will return null.
If you are using WebFlux, do not rely on CyclesContextHolder inside reactive chains. This is a known design constraint for v0 — the library targets Spring MVC and blocking Spring AI workloads.
- Cycles Documentation — full docs site
- Spring Boot Quickstart — getting started guide
- Spring Client Configuration Reference — all configuration options
Apache 2.0