Skip to main content

Why an Annotation Model?

Steps and workflows are the plumbing. Agents are the product — the thing you expose to HTTP endpoints, MCP servers, message listeners, and other agents. The annotation model gives you a programming model for declaring agents, naming them, describing them, handling their errors, and looking them up by name at runtime. If you’ve used @Controller + @ControllerAdvice in Spring MVC, you already know how this works. @Agent is to AgentHandler what @Controller is to your handler methods.

AgentHandler<I, O>

The entry-point contract for agents. A @FunctionalInterface that takes context and input, returns output:
@FunctionalInterface
public interface AgentHandler<I, O> {
    O handle(AgentContext ctx, I input);
}
Lambda form — for simple cases:
AgentHandler<String, String> echo = (ctx, input) -> input.toUpperCase();
String result = echo.handle(AgentContext.create(), "hello"); // "HELLO"
Class-based form — for production agents that compose a Workflow internally:
public class CodeReviewAgent implements AgentHandler<String, ReviewReport> {

    private final Step<String, String> fetchDiff = Step.named("fetch-diff",
            (ctx, prUrl) -> gitHub.fetchDiff(prUrl));

    private final Step<String, ReviewReport> analyze = Step.named("analyze",
            (ctx, diff) -> llm.analyze(diff));

    @Override
    public ReviewReport handle(AgentContext ctx, String prUrl) {
        return Workflow.<String, ReviewReport>define("code-review")
                .step(fetchDiff)
                .then(analyze)
                .run(prUrl, ctx);
    }
}
AgentHandler is pure Java — no Spring dependency. It lives in workflow-api so it’s portable across any runtime.

@Agent

Marks a class as a named agent and registers it as a Spring bean:
@Agent("code-review")
public class CodeReviewAgent implements AgentHandler<String, ReviewReport> {
    // ...
}
@Agent is a Spring @Component stereotype — classes annotated with it are picked up by component scanning. The value() is the unique agent name used for registry lookup and protocol addressing.

@StepName and @Description

Two metadata annotations for steps and agents:
@StepName("analyze-diff")
@Description("Parses a PR diff and extracts changed files and hunks")
public class AnalyzeDiffStep implements Step<PrEvent, String> {
    // ...
}
@StepName pins a stable name for checkpoint and Temporal keys. When you rename the class, the checkpoint key stays the same — crash recovery and Temporal replay still match the right execution record. Without it, Step.name() (which defaults to the class simple name) is used. @Description provides a human-readable description for traces, visualization, and protocol tool listings. Can be placed on types or methods. Both are pure Java annotations — no Spring dependency, no runtime cost.

StepNames utility

StepNames.resolve(step) reads the @StepName annotation; falls back to step.name() if absent:
StepNames.resolve(new AnalyzeDiffStep());  // "analyze-diff" (from annotation)
StepNames.resolve(Step.named("foo", ...)); // "foo" (from name() method)

AgentRegistry

An immutable map of name → handler. Manual construction — you decide what goes in:
AgentRegistry registry = new AgentRegistry(Map.of(
    "code-review", codeReviewAgent,
    "security-audit", securityAuditAgent
));

AgentHandler<?, ?> agent = registry.get("code-review").orElseThrow();
Set<String> names = registry.names();  // ["code-review", "security-audit"]
The registry is defensive-copied at construction (Map.copyOf()). Subsequent changes to the source map don’t affect it.
Auto-configuration that scans @Agent beans and populates AgentRegistry automatically is planned but not yet implemented. For now, construct the registry manually or in a @Configuration class.

Exception Handling

Exception handling mirrors Spring MVC’s @ExceptionHandler + @ControllerAdvice pattern exactly. Two scopes:
  1. Per-agent@ExceptionHandler methods inside an @Agent class handle that agent’s exceptions
  2. Cross-cutting@ExceptionHandler methods inside an @AgentAdvice class handle exceptions from any agent
Per-agent handlers take priority. When multiple handlers match, the most specific exception type wins (subclass beats superclass).

Per-agent handling

@Agent("code-review")
public class CodeReviewAgent implements AgentHandler<String, ReviewReport> {

    @Override
    public ReviewReport handle(AgentContext ctx, String prUrl) {
        return Workflow.<String, ReviewReport>define("code-review")
                .step(fetchDiff)
                .then(analyze)
                .run(prUrl, ctx);
    }

    @ExceptionHandler(RateLimitException.class)
    public ReviewReport handleRateLimit(RateLimitException ex) {
        return new ReviewReport("Rate limited — try again later", "SKIP");
    }

    @ExceptionHandler(IllegalStateException.class)
    public ReviewReport handleBudget(IllegalStateException ex, AgentContext ctx) {
        log.warn("Agent {} exceeded budget", ctx.runId());
        return new ReviewReport("Budget exceeded: " + ex.getMessage(), "SKIP");
    }
}
Handler methods support two signatures:
  • ReturnType method(ExceptionType ex) — just the exception
  • ReturnType method(ExceptionType ex, AgentContext ctx) — exception plus context
The exception type can be declared explicitly in @ExceptionHandler(SomeException.class) or inferred from the method’s first parameter.

Cross-cutting handling

@AgentAdvice
public class GlobalErrorHandler {

    @ExceptionHandler(Exception.class)
    public Object handleAny(Exception ex, AgentContext ctx) {
        log.error("Agent {} failed: {}", ctx.runId(), ex.getMessage());
        return Map.of("error", ex.getMessage(), "runId", ctx.runId());
    }
}
@AgentAdvice is a Spring @Component — picked up by component scanning, applies to all agents.

Using the resolver

AgentExceptionHandlerResolver ties it together. Pass it the @AgentAdvice instances at construction, then call resolve() on exception:
var resolver = new AgentExceptionHandlerResolver(List.of(globalErrorHandler));

try {
    return agent.handle(ctx, input);
} catch (Exception ex) {
    return resolver.resolve(agent, ex, ctx)
            .orElseThrow(() -> ex);  // re-throw if no handler matched
}
Resolution order: per-agent handlers on the agent instance first, then cross-cutting advice in registration order.

Spring Boot Wiring

In a Spring Boot application, wire the registry and resolver as beans in a @Configuration class:
@Configuration(proxyBeanMethods = false)
public class AgentConfig {

    @Bean
    AgentExceptionHandlerResolver agentExceptionHandlerResolver(
            MyErrorAdvice errorAdvice) {  // @AgentAdvice bean
        return new AgentExceptionHandlerResolver(List.of(errorAdvice));
    }

    @Bean
    AgentRegistry agentRegistry(PrReviewAgent prReviewAgent) {
        return new AgentRegistry(Map.of("pr-review", prReviewAgent));
    }
}
Spring injects the @Agent and @AgentAdvice beans by type. The registry and resolver become injectable anywhere you need them — HTTP controllers, message listeners, MCP tool handlers.

Disambiguating steps with @Qualifier

When two steps share the same Step<I, O> signature, Spring can’t tell them apart by type alone. Use @Qualifier on both the bean definition and the injection point:
@Component
@Qualifier("assess-code-quality")
@StepName("assess-code-quality")
public class AssessCodeQualityStep implements Step<PrContext, AssessmentResult> {
    // ...
}

@Component
@Qualifier("assess-backport")
@StepName("assess-backport")
public class AssessBackportStep implements Step<PrContext, AssessmentResult> {
    // ...
}
Then inject by qualifier in the agent constructor:
@Agent("pr-review")
public class PrReviewAgent implements AgentHandler<PrEvent, ReviewReport> {

    public PrReviewAgent(
            @Qualifier("assess-code-quality") Step<PrContext, AssessmentResult> quality,
            @Qualifier("assess-backport") Step<PrContext, AssessmentResult> backport) {
        // ...
    }
}
This is the standard Spring pattern — @StepName gives you a stable checkpoint key, @Qualifier gives you injection disambiguation.

Injecting Durability with withExecutor()

By default, Workflow.run() creates a WorkflowExecutor with LocalStepRunner (in-process, no persistence). Use withExecutor() to inject a configured executor with a durable StepRunner:
// Configure executor with JDBC checkpointing
WorkflowExecutor executor = new WorkflowExecutor(
        checkpointingStepRunner,
        jdbcTraceRecorder
);

// Inject into workflow — both run() and build() paths work
String result = Workflow.<String, String>define("durable-review")
        .withExecutor(executor)
        .step(fetchDiff)
        .then(analyze)
        .run(prUrl, ctx);

// Or build a reusable Workflow that remembers the executor
Workflow<String, String> workflow = Workflow.<String, String>define("review")
        .withExecutor(executor)
        .step(fetchDiff)
        .then(analyze)
        .build();

workflow.execute(ctx, prUrl);  // uses the injected executor
This works on both WorkflowBuilder and SupervisorBuilder. See Durability for the full graduation path from LocalStepRunnerCheckpointingStepRunnerTemporalStepRunner.

Full Example

A complete PR review agent using all 5 annotations:
// -- Domain types --
record PrEvent(String prUrl, String diff) {}
record ReviewReport(String summary, String recommendation) {}

// -- Steps with stable names --
@StepName("analyze-diff")
@Description("Parses a PR diff and extracts changed files")
public class AnalyzeDiffStep implements Step<PrEvent, String> {
    @Override
    public String execute(AgentContext ctx, PrEvent event) {
        return llm.analyze(event.diff());
    }
    @Override public String name() { return "AnalyzeDiffStep"; }
}

@StepName("produce-review")
@Description("Produces a structured review report")
public class ProduceReviewStep implements Step<String, ReviewReport> {
    @Override
    public ReviewReport execute(AgentContext ctx, String analysis) {
        return new ReviewReport(analysis, "LGTM");
    }
    @Override public String name() { return "ProduceReviewStep"; }
}

// -- Agent --
@Agent("pr-review")
@Description("Reviews a pull request and produces a structured report")
public class PrReviewAgent implements AgentHandler<PrEvent, ReviewReport> {

    private final Step<PrEvent, String> analyze = new AnalyzeDiffStep();
    private final Step<String, ReviewReport> review = new ProduceReviewStep();

    @Override
    public ReviewReport handle(AgentContext ctx, PrEvent event) {
        return Workflow.<PrEvent, ReviewReport>define("pr-review")
                .step(analyze)
                .then(review)
                .run(event, ctx);
    }

    @ExceptionHandler(IllegalStateException.class)
    public ReviewReport handleBudget(IllegalStateException ex) {
        return new ReviewReport("Budget exceeded: " + ex.getMessage(), "SKIP");
    }
}

// -- Cross-cutting error handling --
@AgentAdvice
public class GlobalErrorHandler {
    @ExceptionHandler(Exception.class)
    public Object handleAny(Exception ex, AgentContext ctx) {
        return new ReviewReport("Error: " + ex.getMessage(), "ERROR");
    }
}

// -- Wiring --
AgentRegistry registry = new AgentRegistry(Map.of(
    "pr-review", new PrReviewAgent()
));

var resolver = new AgentExceptionHandlerResolver(
    List.of(new GlobalErrorHandler())
);
A runnable version of this example — with real LLM calls, AgentRegistry lookup, StepNames resolution, and exception handler tests — is in AnnotationModelIT.java in the workflow-dsl-examples repo.

Annotation Summary

AnnotationTargetModulePurpose
@StepNameTypeworkflow-apiStable checkpoint/Temporal key
@DescriptionType, Methodworkflow-apiHuman-readable description
@ExceptionHandlerMethodworkflow-apiMarks exception handler methods
@AgentTypeworkflow-flowsSpring stereotype for AgentHandler beans
@AgentAdviceTypeworkflow-flowsSpring stereotype for cross-cutting error handlers
The first three are pure Java — zero Spring dependency, portable across any runtime. The last two are Spring stereotypes that enable component scanning and auto-discovery.

What’s Next

Durability

Crash recovery with CheckpointingStepRunner and Temporal integration

DSL Primitives

Branch, loop, parallel, decision, gate, supervisor — 10+ composable patterns

API Reference

Step, AgentContext, Gate, WorkflowGraph, StepRunner

Getting Started

Steps, context, your first workflow