05-embabel-agent-orchestration

Building Agents: @Agent, @Action & @AchievesGoal

A real Embabel agent, built from the framework's own official bank-support example and adapted to Acme Fintech: @Agent, @Action, the Blackboard in practice, unit testing, and running it from a shell and a REST controller.

August 13, 2026
spring-aiembabelagentactionachievesgoaloperationcontext

From Concept to a Running Agent

The last guide explained why Embabel plans instead of prompting an LLM to orchestrate itself — GOAP, the Blackboard, replanning as an OODA loop. This guide builds the real thing: a Spring-managed @Agent that Acme Fintech's support team could actually run, adapted from Embabel's own official bank-support example (Embabel ships an almost identical one in its public examples repository — this guide's first agent is only lightly renamed from it).


1. Setup

Embabel moves fast enough that a version number printed in an article today is a reasonable way to end up with a dependency that no longer resolves by the time you read it. Its own quick start reflects that: rather than hand-writing a build.gradle entry, start from the official project template the same way this roadmap's first guide pointed you at start.spring.io:

Once you have a project, add embabel-agent-starter-ollama (groupId: com.embabel.agent) alongside whichever LLM starter the template already included, to keep this roadmap's Ollama-first, no-API-key approach going. One naming difference from every other guide in this roadmap: Embabel reads OPENAI_API_KEY and ANTHROPIC_API_KEY directly, not Spring AI's SPRING_AI_OPENAI_API_KEY — a deliberate choice by the Embabel team for consistency with the wider ecosystem, not an oversight.

Requirements: Java 21+, and Spring Boot's usual @SpringBootApplication entry point — no special annotation beyond that is needed to enable agent scanning; the starter's auto-configuration handles it.


2. Your First Agent: A Bank Support Case

Here's a complete, working @Agent — one class, one action, doing real judgment work:

java
record Customer(Long id, String name, float balance, float pendingAmount) {
 
    @Tool(description = "Find the balance of a customer by id")
    float balance(boolean includePending) {
        return includePending ? balance + pendingAmount : balance;
    }
}
 
interface CustomerRepository extends Repository<Customer, Long> {
    @Nullable
    Customer findById(Long id);
}
 
record SupportInput(
    @JsonPropertyDescription("Customer ID") Long customerId,
    @JsonPropertyDescription("Query from the customer") String query
) {}
 
record SupportOutput(
    @JsonPropertyDescription("Advice returned to the customer") String advice,
    @JsonPropertyDescription("Whether to block their card or not") boolean blockCard,
    @JsonPropertyDescription("Risk level of query, 0-10") int risk
) {}
 
@Agent(description = "Customer support agent")
record SupportAgent(CustomerRepository customerRepository) {
 
    @AchievesGoal(description = "Help a bank customer with their query")
    @Action
    SupportOutput supportCustomer(SupportInput input, OperationContext context) {
        var customer = customerRepository.findById(input.customerId());
        if (customer == null) {
            return new SupportOutput("Customer not found with this id", false, 0);
        }
        return context.ai()
            .withLlm(OpenAiModels.GPT_41_MINI)
            .withToolObject(customer)
            .createObject("""
                You are a support agent in our bank. Give the customer support
                and judge the risk level of their query.
                In some cases you may need to block their card — if so, explain why.
                Reply using the customer's name, "%s". Currencies are in $.
 
                Their query: [%s]
                """.formatted(customer.name(), input.query()),
                SupportOutput.class);
    }
}

A lot is packed into that one method, all of it worth unpacking:

  • @Agent on a record. Embabel agents are ordinary Spring beans — a record works exactly like a class would, and here it doubles as the constructor for dependency injection (CustomerRepository arrives via Spring, no @Autowired needed).
  • OperationContext context, then context.ai(). This is one of two equally valid ways to reach the LLM gateway — you'll also see Ai ai injected as a direct method parameter in other Embabel code (including this roadmap's next example). Both work; OperationContext additionally gives you blackboard and sub-process access if an action needs it.
  • .withToolObject(customer). The Customer record's own balance(boolean) method is annotated @Tool — the exact same Spring AI annotation from this roadmap's tool-calling guide. Passing the domain object itself as a tool source means the model can call balance(true) mid-reasoning to check pending amounts, without a separate @Component tool class.
  • @JsonPropertyDescription on every output field. This is the same lesson as BeanOutputConverter from the structured-output guide, just spelled with Jackson's own annotation instead of a Spring AI-specific one: the description is the schema instruction the model sees, not documentation for humans.
  • The risk judgment and card-blocking decision live inside the prompt, not in Java. Nothing external branches between "block the card" and "don't" — the model decides, and expresses that decision as a typed field (blockCard) your application code can act on deterministically, even though how the model arrived at it wasn't deterministic.

3. Multi-Action Agents: the Blackboard in Practice

One action is enough for a self-contained judgment call. For anything that's naturally a pipeline — extract, look something up, decide, respond — split it into several @Action methods and let the typed parameters chain them, exactly as the last guide described in the abstract:

java
record RefundRequest(String customerMessage) {}
record ExtractedOrder(Long orderId, String reason) {}
record OrderLookup(Long orderId, boolean withinWindow, float amount) {}
record RefundResponse(String text) {}
 
@Agent(description = "Look up and explain a refund request")
class RefundResearchAgent {
 
    private final OrderRepository orderRepository;
 
    RefundResearchAgent(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
 
    @Action
    ExtractedOrder extractOrder(RefundRequest request, Ai ai) {
        return ai.withDefaultLlm()
            .createObject("Extract the order ID and stated reason from: " + request.customerMessage(),
                ExtractedOrder.class);
    }
 
    // No LLM in this one — Embabel is just as happy mixing plain code
    // into the chain as it is chaining LLM calls.
    @Action
    OrderLookup lookupOrder(ExtractedOrder extracted) {
        var order = orderRepository.findById(extracted.orderId());
        boolean withinWindow = order.daysSincePurchase() <= order.tierRefundWindowDays();
        return new OrderLookup(extracted.orderId(), withinWindow, order.amount());
    }
 
    @AchievesGoal(description = "Explain the refund outcome to the customer")
    @Action
    RefundResponse explain(OrderLookup lookup, ExtractedOrder extracted, Ai ai) {
        return ai.withDefaultLlm()
            .createObject("""
                Explain this refund outcome to the customer in 2-3 sentences.
                Order %d, stated reason: "%s". Within refund window: %s. Amount: $%.2f.
                """.formatted(lookup.orderId(), extracted.reason(), lookup.withinWindow(), lookup.amount()),
                RefundResponse.class);
    }
}

Trace the types, not the method order: extractOrder needs a RefundRequest and produces an ExtractedOrder. lookupOrder needs an ExtractedOrder — which now exists on the blackboard — and produces an OrderLookup. explain needs both OrderLookup and ExtractedOrder, both of which are available by the time it can run. Nobody wrote "run extractOrder, then lookupOrder, then explain" anywhere — the planner worked out that ordering purely from which types feed which methods, and it's the same mechanism whether the chain has three actions or thirty.

Embabel's own documented concept list includes @Condition methods for branching driven by an explicit boolean check on blackboard data, re-evaluated after every action — genuinely useful once a step's applicability depends on more than "is this type available yet." Every official example agent, including both of this guide's, gets by on type-driven chaining alone; treat @Condition as a tool to reach for once you hit a concrete need for it, and check Embabel's current reference docs for exact syntax, since this corner of the API is still moving.


4. Testing Without Calling a Real Model

Embabel agents are plain Java objects — call the method directly in a unit test, and swap the real OperationContext for FakeOperationContext to assert on the prompt without spending a token:

java
class SupportAgentTest {
 
    @Test
    void promptIncludesCustomerNameAndQuery() {
        CustomerRepository repo = mock(CustomerRepository.class);
        when(repo.findById(42L)).thenReturn(new Customer(42L, "Lynda", 500f, 0f));
 
        var agent = new SupportAgent(repo);
        var context = new FakeOperationContext();
        context.expectResponse(new SupportOutput("All good, Lynda", false, 1));
 
        agent.supportCustomer(new SupportInput(42L, "What's my balance?"), context);
 
        var prompt = context.getLlmInvocations().getFirst().getPrompt();
        assertTrue(prompt.contains("Lynda"));
        assertTrue(prompt.contains("What's my balance?"));
    }
}

FakeOperationContext.expectResponse(...) primes what the fake LLM call returns; getLlmInvocations() lets you assert on exactly what prompt your action actually constructed — the same discipline as any other unit test, just aimed at the prompt-building logic instead of a return value alone.


5. Running It: Shell, and a REST Endpoint

During development, Embabel's Spring Shell integration is the fastest feedback loop — no controller, no curl, just a command:

java
@ShellComponent
public record SupportAgentShellCommands(AgentPlatform agentPlatform) {
 
    @ShellMethod("Get bank support for a customer query")
    public String bankSupport(
        @ShellOption(defaultValue = "42") Long id,
        @ShellOption(defaultValue = "What's my balance including pending?") String query
    ) {
        var invocation = AgentInvocation.builder(agentPlatform)
            .options(ProcessOptions.DEFAULT.withVerbosity(Verbosity.DEFAULT.withShowPrompts(true)))
            .build(SupportOutput.class);
        return invocation.invoke(new SupportInput(id, query)).toString();
    }
}
text
shell:> bank-support --id 42 --query "I think I was charged twice"

The -p-equivalent verbosity option logs the actual prompt sent to the model — genuinely useful while you're still shaping an action's prompt and want to see exactly what the framework generated around it.

The exact same AgentPlatform bean and AgentInvocation builder work identically from a normal Spring MVC controller — this is how the fintech team's actual production support endpoint would call it, no shell involved:

java
@RestController
@RequestMapping("/api/support")
public class SupportController {
 
    private final AgentPlatform agentPlatform;
 
    public SupportController(AgentPlatform agentPlatform) {
        this.agentPlatform = agentPlatform;
    }
 
    @PostMapping
    public SupportOutput support(@RequestBody SupportInput input) {
        var invocation = AgentInvocation.builder(agentPlatform).build(SupportOutput.class);
        return invocation.invoke(input);
    }
}

@AchievesGoal also accepts export = @Export(remote = true, ...), marking a goal for exposure outside the process it runs in — the same interoperability idea as this roadmap's MCP guide, just for an Embabel goal instead of a plain @Tool. If Acme Fintech's Platform team wants SupportAgent's judgment available the same way OrderTools.getRefundStatus was exposed over MCP, this is the annotation that starts that path — check Embabel's current MCP server documentation for the exact starter and wiring, which is still evolving alongside the rest of the framework.


What's Next

You've built and run a real, typed, Spring-managed Embabel agent — a far cry from the abstract GOAP description in the last guide. The final guide in this phase steps back and gives you the actual decision framework: when this genuinely earns its complexity over plain Spring AI, and when it doesn't.

Frequently asked questions

Do I need to learn Kotlin to use Embabel?

No — Embabel is written in Kotlin but every example in this guide, and every example in Embabel's own official examples repository, is plain Java. The framework explicitly targets "a natural usage model from Java" as a design goal, not an afterthought.

What's the difference between injecting Ai directly versus OperationContext?

Ai gives you exactly the LLM gateway (withLlm, createObject, and so on). OperationContext gives you that same gateway via .ai(), plus access to blackboard internals and sub-process control for more advanced flows. Default to whichever the method actually needs — most actions, including every one in this guide, only need Ai.

Can an @Action call another Spring bean that isn't itself Embabel-aware?

Yes — lookupOrder in this guide's second example calls a plain Spring Data OrderRepository with no Embabel-specific code in it at all. Because @Agent classes are ordinary Spring beans, they can depend on and call anything else in your application context the normal way.

Is FakeOperationContext only for testing single actions, or can it test a whole multi-action agent?

It's built around priming and inspecting individual LLM invocations, which makes it most natural for testing one action's prompt-building logic at a time — call each @Action method directly in its own test, the way this guide's example calls supportCustomer directly, rather than trying to drive an entire multi-step plan through it.