Getting Started with the Cycles Spring Boot Starter
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.
Using Python?
See the Python Client quickstart instead.
What the starter does
The starter wraps any annotated method in a reserve → execute → commit lifecycle:
- Before the method runs: evaluates the estimate, creates a reservation, and checks the decision
- While the method runs: maintains the reservation with automatic heartbeat extensions
- After the method returns: commits actual usage and releases any unused remainder
- 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.
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 first. The demo app expects the same acme-corp tenant used in that guide.
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.
Run the demo
git clone https://github.com/runcycles/cycles-spring-boot-starter.git
cd cycles-spring-boot-starter/cycles-demo-client-java-springSet your API key — the cyc_live_... value returned when you created the key via the Admin Server:
export CYCLES_API_KEY=cyc_live_... # from Admin Server /v1/admin/api-keys response
mvn spring-boot:runOr edit application.yml directly and paste the key in cycles.api-key.
The demo app starts on port 7955. Try the simplest example first:
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.
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/*)
@Cycleswith SpEL estimate/actual,CyclesContextHolderfor reading reservation context,CyclesMetricsfor reporting token counts and latency, andcommitMetadatafor audit data
Annotation variations (/api/demo/annotation/*)
ALLOW_WITH_CAPS— reading and respecting operator-configured constraints returned by the server —POST /api/demo/annotation/capsunit=TOKENSwithactionTags—POST /api/demo/annotation/tokensunit=CREDITSwithworkflow,agent, and customdimensions—POST /api/demo/annotation/credits- Per-annotation budget scope targeting (
workspace/appoverride) —POST /api/demo/annotation/budget-targeting overagePolicy=ALLOW_WITH_OVERDRAFT—POST /api/demo/annotation/overdraft- Custom
ttlMsandgracePeriodMs—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
@RestControllerAdviceforCyclesProtocolExceptionwith structured JSON error responses
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
curl -X POST http://localhost:7955/api/demo/annotation/minimal?input=helloThis is @Cycles("1000") — the simplest annotation. The response shows the reservation lifecycle result.
2. Check your balance
curl http://localhost:7955/api/demo/client/balancesYou should see spent increased by 1000 microcents from step 1.
3. Try a dry run (no budget consumed)
curl -X POST http://localhost:7955/api/demo/annotation/dry-run?amount=500The server evaluates the reservation but doesn't persist it. Check balances again — they haven't changed.
4. See how overdraft works
curl -X POST http://localhost:7955/api/demo/annotation/overdraft?amount=1000With overagePolicy=ALLOW_WITH_OVERDRAFT, the reservation succeeds even if it exceeds the budget.
5. Use the programmatic client
curl -X POST http://localhost:7955/api/demo/client/reserve-commit?estimate=5000This 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
curl -X POST http://localhost:7955/api/demo/client/reserve-release?estimate=3000The reservation is created then released — budget is returned to the pool. Check balances to confirm it was refunded.
7. Preflight check without reserving
curl -X POST http://localhost:7955/api/demo/client/decide?estimate=10000The decide endpoint tells you whether a reservation would be allowed, without creating one.
8. Record a standalone event
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:
<dependency>
<groupId>io.runcycles</groupId>
<artifactId>cycles-client-java-spring</artifactId>
<version>0.3.2</version>
</dependency>implementation 'io.runcycles:cycles-client-java-spring:0.3.2'Check Maven Central for the latest version.
Configuration
Configure the connection in your project's application.yml:
cycles:
base-url: http://localhost:7878
api-key: your-api-key
tenant: acme
workspace: production
app: support-botThese defaults apply to all @Cycles-annotated methods unless overridden per method.
Optional configuration
cycles:
http:
connect-timeout: 2s
read-timeout: 5s
retry:
enabled: true
max-attempts: 5
initial-delay: 500ms
multiplier: 2.0
max-delay: 30sThe @Cycles annotation
The @Cycles annotation is applied to methods:
@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:
@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.
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:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>Alternatively, use positional references: #p0, #p1, etc. See the SpEL Expression Reference 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:
@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.
Annotation attributes
Subject fields
Override the defaults from configuration:
@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:
// 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
@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
@Cycles(value = "2500", unit = "TOKENS")Supported units: USD_MICROCENTS (default), TOKENS, CREDITS, RISK_POINTS.
TTL and grace period
@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.
Overage policy
@Cycles(value = "1000", overagePolicy = "ALLOW_IF_AVAILABLE")Options: ALLOW_IF_AVAILABLE (default), REJECT, ALLOW_WITH_OVERDRAFT.
Dry run (shadow mode)
@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
@Cycles(value = "1000", dimensions = {"cost_center=engineering", "run=run-12345"})Commit metadata
@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<String, Object>. 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:
@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:
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.
// 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:
@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:
@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 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.
// 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
}
}
@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.
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:
- Estimate is evaluated (SpEL expression or fixed value)
- Reservation is created on the Cycles server
- Decision is checked (ALLOW / ALLOW_WITH_CAPS / DENY)
- If DENY: throw exception, method does not run
- Heartbeat extension is scheduled (background thread)
- Method executes
- Actual usage is evaluated (SpEL expression or estimate)
- Commit is sent with actual amount and optional metrics
- Heartbeat is cancelled
- 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
- Run the Cycles Server
- Manage budgets with Cycles Admin
- Integrate with Python using the Python Client
- Integrate with TypeScript using the TypeScript Client
- Integrate with Spring Boot or Spring AI using the Spring Boot starter or the Spring AI starter