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
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.
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 one you created in the deployment guide):
export CYCLES_API_KEY=cyc_live_...
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 server-imposed constraints —POST /api/demo/annotation/capsunit=TOKENSwithactionTags—POST /api/demo/annotation/tokensunit=CREDITSwithworkflow,agent, and customdimensions—POST /api/demo/annotation/creditsoveragePolicy=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_MICROCENTSbudget. Theunit=TOKENSandunit=CREDITSdemo endpoints require separate budget ledgers for those units. If you only created the default budget, those endpoints will returnBUDGET_EXCEEDED. Start with theUSD_MICROCENTSendpoints (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 accounting.
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 |
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/.
Configuration
Add the starter dependency and configure the connection in your project's application.yml:
cycles:
base-url: https://your-cycles-server.example.com
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.
Specifying actual cost
By default, the estimate is used as the actual cost at commit time. To calculate actual cost 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")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. The starter automatically sends heartbeat extensions at ttlMs / 2 intervals.
Overage policy
@Cycles(value = "1000", overagePolicy = "ALLOW_IF_AVAILABLE")Options: REJECT (default), ALLOW_IF_AVAILABLE, ALLOW_WITH_OVERDRAFT.
Dry run (shadow mode)
@Cycles(value = "1000", dryRun = true)Evaluates the reservation without actually holding budget. Useful for shadow-mode rollouts.
Custom dimensions
@Cycles(value = "1000", dimensions = {"cost_center=engineering", "run=run-12345"})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("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();
}Nesting prevention
The starter does not allow nested @Cycles annotations. If method A is annotated with @Cycles and calls method B which is also annotated, an IllegalStateException is thrown.
This prevents double-reservation and ensures each budget lifecycle is clear and isolated.
Commit retry
If the commit fails due to a transient error (network issue, 5xx response), the starter can retry automatically.
The retry engine is configurable and extensible. The default implementation uses exponential backoff based on the retry configuration.
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 cost 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
- Commit retry handles transient failures automatically
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 AI using the Spring Client
