Skip to content

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:

  1. Before the method runs: evaluates the estimate, creates a reservation, and checks the decision
  2. While the method runs: maintains the reservation with automatic heartbeat extensions
  3. After the method returns: commits actual usage and releases any unused remainder
  4. 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

bash
git clone https://github.com/runcycles/cycles-spring-boot-starter.git
cd cycles-spring-boot-starter/cycles-demo-client-java-spring

Set your API key (the one you created in the deployment guide):

bash
export CYCLES_API_KEY=cyc_live_...
mvn spring-boot:run

Or edit application.yml directly and paste the key in cycles.api-key.

The demo app starts on port 7955. Try the simplest example first:

bash
curl -X POST http://localhost:7955/api/demo/annotation/minimal?input=hello

Hit 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/*)

  • @Cycles with SpEL estimate/actual, CyclesContextHolder for reading reservation context, CyclesMetrics for reporting token counts and latency, and commitMetadata for audit data

Annotation variations (/api/demo/annotation/*)

  • ALLOW_WITH_CAPS — reading and respecting server-imposed constraints — POST /api/demo/annotation/caps
  • unit=TOKENS with actionTagsPOST /api/demo/annotation/tokens
  • unit=CREDITS with workflow, agent, and custom dimensionsPOST /api/demo/annotation/credits
  • overagePolicy=ALLOW_WITH_OVERDRAFTPOST /api/demo/annotation/overdraft
  • Custom ttlMs and gracePeriodMsPOST /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 @RestControllerAdvice for CyclesProtocolException with 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

bash
curl -X POST http://localhost:7955/api/demo/annotation/minimal?input=hello

This is @Cycles("1000") — the simplest annotation. The response shows the reservation lifecycle result.

2. Check your balance

bash
curl http://localhost:7955/api/demo/client/balances

You should see spent increased by 1000 microcents from step 1.

3. Try a dry run (no budget consumed)

bash
curl -X POST http://localhost:7955/api/demo/annotation/dry-run?amount=500

The server evaluates the reservation but doesn't persist it. Check balances again — they haven't changed.

4. See how overdraft works

bash
curl -X POST http://localhost:7955/api/demo/annotation/overdraft?amount=1000

With overagePolicy=ALLOW_WITH_OVERDRAFT, the reservation succeeds even if it exceeds the budget.

5. Use the programmatic client

bash
curl -X POST http://localhost:7955/api/demo/client/reserve-commit?estimate=5000

This 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

bash
curl -X POST http://localhost:7955/api/demo/client/reserve-release?estimate=3000

The reservation is created then released — budget is returned to the pool. Check balances to confirm it was refunded.

7. Preflight check without reserving

bash
curl -X POST http://localhost:7955/api/demo/client/decide?estimate=10000

The decide endpoint tells you whether a reservation would be allowed, without creating one.

8. Record a standalone event

bash
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

FileWhat it demonstrates
service/LlmService.java@Cycles annotation, CyclesContextHolder, CyclesMetrics, commitMetadata
service/AnnotationShowcaseService.javaAnnotation variations: minimal, caps-aware, units, TTL, overdraft, dry-run, dimensions
service/ProgrammaticClientService.javaDirect CyclesClient usage for the full reservation lifecycle
service/EventService.javaStandalone events via CyclesClient.createEvent()
error/CyclesExceptionHandler.javaGlobal error handling for CyclesProtocolException
resolvers/CyclesTenantResolver.javaDynamic tenant resolution via CyclesFieldResolver
controller/DemoController.javaREST endpoints wiring all services at /api/demo/*
controller/LlmController.javaLLM 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:

yaml
cycles:
  base-url: https://your-cycles-server.example.com
  api-key: your-api-key
  tenant: acme
  workspace: production
  app: support-bot

These defaults apply to all @Cycles-annotated methods unless overridden per method.

Optional configuration

yaml
cycles:
  http:
    connect-timeout: 2s
    read-timeout: 5s
  retry:
    enabled: true
    max-attempts: 5
    initial-delay: 500ms
    multiplier: 2.0
    max-delay: 30s

The @Cycles annotation

The @Cycles annotation is applied to methods:

java
@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:

java
@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:

java
@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:

java
@Cycles(value = "1000",
         tenant = "acme",
         workspace = "production",
         app = "support-bot",
         workflow = "refund-assistant",
         agent = "planner",
         toolset = "search-tools")

Action identity

java
@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

java
@Cycles(value = "2500", unit = "TOKENS")

Supported units: USD_MICROCENTS (default), TOKENS, CREDITS, RISK_POINTS.

TTL and grace period

java
@Cycles(value = "1000", ttlMs = 30000, gracePeriodMs = 10000)

Default TTL is 60 seconds. The starter automatically sends heartbeat extensions at ttlMs / 2 intervals.

Overage policy

java
@Cycles(value = "1000", overagePolicy = "ALLOW_IF_AVAILABLE")

Options: REJECT (default), ALLOW_IF_AVAILABLE, ALLOW_WITH_OVERDRAFT.

Dry run (shadow mode)

java
@Cycles(value = "1000", dryRun = true)

Evaluates the reservation without actually holding budget. Useful for shadow-mode rollouts.

Custom dimensions

java
@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:

java
@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:

java
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:

  1. Estimate is evaluated (SpEL expression or fixed value)
  2. Reservation is created on the Cycles server
  3. Decision is checked (ALLOW / ALLOW_WITH_CAPS / DENY)
  4. If DENY: throw exception, method does not run
  5. Heartbeat extension is scheduled (background thread)
  6. Method executes
  7. Actual cost is evaluated (SpEL expression or estimate)
  8. Commit is sent with actual amount and optional metrics
  9. Heartbeat is cancelled
  10. 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: