Embabel is an open-source agent framework for the JVM, created by Rod Johnson and built on Spring AI, in which we write an agent as a Java class with @Action methods and a planner decides at runtime which actions to run to reach a goal. Each action takes typed domain objects as parameters and returns a new one, so Embabel knows what every step needs and what it produces.
We use Embabel when one LLM call is not enough and a Spring Boot app has to combine LLM steps with its own services, for example in support ticket triage or a travel planner.
The following example runs a small Embabel agent that turns a reader’s message into a reading plan. The agent has three actions, and we never tell Embabel in which order to call them.
AgentInvocation<ReadingAdvice> invocation =
AgentInvocation.create(agentPlatform, ReadingAdvice.class);
ReadingAdvice advice = invocation.invoke(
new UserInput("Hi, I am Lokesh. I want to read a fantasy book in 10 days."));
// Plan formed by Embabel: extractRequest -> findBooks -> planReading
// advice = ReadingPlan[reader=Lokesh, title=The Hobbit, pagesPerDay=31,
// note=You've got this, Lokesh! The Hobbit is a bit easier to tackle ...]
Notice that we only ask for the result type ReadingAdvice. Embabel finds the agent that produces it and plans the steps from the method signatures.
Next, we compare Embabel with Spring AI and LangChain4j. After that, we build the agent with a local Ollama model and test it without an API key.
1. What Is Embabel?
Embabel (pronounced Em-BAY-bel) is a framework for writing LLM agents in Java and Kotlin. Rod Johnson, the creator of the Spring Framework, started it, and it uses Spring AI to call the model providers. Embabel 1.0 reached Maven Central in July 2026 on Spring Boot 3.5, and the 1.5 line moved to Spring Boot 4.1.
An agent is a Spring bean annotated with @Agent, and its methods are the steps. The planner that chains them is not an LLM. It uses Goal Oriented Action Planning (GOAP), an algorithm that searches for the cheapest sequence of actions that leads to the goal.
1.1. Actions, Goals, Conditions and Domain Objects
Embabel models every agent with four building blocks, and each one maps to an annotation or a plain Java type.
| Building block | How we declare it | What the planner does with it | In our example |
|---|---|---|---|
| Domain object | A record or class | Tracks which objects exist on the blackboard (the shared state of one run) | ReadingRequest, Shelf, ReadingPlan |
| Action | @Action on a method | Parameter types become preconditions, the return type becomes an effect | extractRequest(), findBooks() |
| Goal | @AchievesGoal on an action | Plans a path to the action that returns the goal type | planReading(), noBooksFound() |
| Condition | @Condition on a boolean method | Evaluates the method after each step and gates actions through pre | hasBooks, emptyShelf |
For example, the method Shelf findBooks(ReadingRequest request) tells Embabel two things. The action can run only when a ReadingRequest is on the blackboard, and after it runs, a Shelf is there too.
An action body can call an LLM, a Spring service, a database or nothing at all, because the planner only looks at the types and the conditions.
1.2. How Is Embabel Different From Spring AI and LangChain4j?
With Spring AI on its own, we call ChatClient from a service method, and our own if statements decide the order of the calls. That works well for one or two LLM calls, but the orchestration code grows with every new step.
LangChain4j has its own model clients and an experimental agentic module. There we declare agents as annotated interfaces and wire them into workflows with builders such as sequenceBuilder() and conditionalBuilder(), or let an LLM supervisor choose the next agent.
In Embabel, we neither wire a workflow nor ask an LLM to plan it. The GOAP planner derives the order from the types and conditions, so a new action with new types extends the agent without changes to the existing actions.

In daily work, the biggest differences are where the shared state is kept and how much test support each library gives us.
| Topic | Spring AI alone | LangChain4j agentic | Embabel |
|---|---|---|---|
| Unit of work | A ChatClient call | An @Agent interface method | An @Action method on an @Agent class |
| Who orders the steps | Our Java code | A wired workflow or an LLM supervisor | The GOAP planner, at runtime |
| Shared state | Local variables | AgenticScope with string keys | Typed blackboard of domain objects |
| Tools | @Tool methods | @Tool methods | @LlmTool methods |
| Model access | Spring AI | LangChain4j model clients | Spring AI underneath |
| Test support | Mock ChatModel ourselves | Mock the model ourselves | FakeOperationContext and EmbabelMockitoIntegrationTest |
Embabel adds planning on top of Spring AI and still uses Spring AI for every model call. If a feature needs one prompt and one answer, Spring AI’s ChatClient is less code. Embabel pays off once a flow has several steps and branches.
2. Setting Up Embabel With Spring Boot
Embabel publishes its releases to Maven Central. We import the embabel-agent-dependencies BOM and add one starter for the model provider, and the Ollama starter also brings the agent platform itself.
The example uses Embabel 1.5.2 (the latest release, published on September 16, 2026), which is built on Spring Boot 4.1.1 and Spring AI 2.0.1, on Java 25.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.embabel.agent</groupId>
<artifactId>embabel-agent-dependencies</artifactId>
<version>1.5.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.embabel.agent</groupId>
<artifactId>embabel-agent-starter-ollama</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.embabel.agent</groupId>
<artifactId>embabel-agent-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
The provider starter decides which models Embabel can use, and we can add more than one to mix providers.
| Starter | Use it for |
|---|---|
| embabel-agent-starter-ollama | Local models served by Ollama |
| embabel-agent-starter-openai | OpenAI models, needs OPENAI_API_KEY |
| embabel-agent-starter-anthropic | Anthropic models, needs ANTHROPIC_API_KEY |
| embabel-agent-starter-openai-custom | OpenAI-compatible services such as Groq or OpenRouter |
| embabel-agent-starter-shell | An interactive Spring Shell console for trying agents |
| embabel-agent-starter-mcpserver | Publishing agent goals as Model Context Protocol (MCP) tools over HTTP |
| embabel-agent-starter | The platform only, without a model provider |
2.1. Running a Local Model With Ollama
A local model lets us run the agent without a cloud API key. We start Ollama in Docker and pull a model that supports tool calling, such as qwen2.5. The steps are the same as for Ollama with Spring AI.
docker run -d --name ollama -p 11434:11434 ollama/ollama
docker exec ollama ollama pull qwen2.5:7b
Embabel reads the Ollama URL from the Spring AI property and discovers the pulled models at startup. The embabel.models.default-llm property picks the default model.
spring:
ai:
ollama:
base-url: http://localhost:11434
embabel:
models:
default-llm: qwen2.5:7b
agent:
platform:
llm-operations:
prompts:
default-timeout: 5m # default 60s, too short for a CPU-only model
data-binding:
max-attempts: 3 # default 10 retries when the JSON doesn't parse
A 7B model without a GPU can need more than the default 60 seconds for a prompt with a JSON schema and tools, so we raise default-timeout.
At startup, the log lists the discovered models and the deployed agent.
OllamaModelsConfig - Discovered 3 Ollama models from default instance: [qwen2.5-7b, qwen2.5-1.5b, qwen2.5-3b]
ConfigurableModelProvider - Default LLM: qwen2.5:7b
ChatClientLlmOperations - Current LLM settings: maxAttempts=3, fixedBackoffMillis=30ms, timeout=300s
Embabel - Deployed agent ReadingPlanAgent
description: Creates a reading plan from a reader's message and the books in the library
3. Embabel Agent Example
A library app gets messages such as “I want to read a fantasy book in 10 days”. It has to understand the request and look up matching books in its catalog before it tells the reader how many pages to read per day. If the library has no books in that genre, the app says so without asking the LLM.
The following example is the agent behind that feature. The extractRequest() action asks the LLM for a ReadingRequest, and findBooks() reads a Shelf from the LibraryCatalog service. The LibraryCatalog is a plain @Service that keeps a few books per genre in a Map. The goal actions planReading() and noBooksFound() are gated by the conditions hasBooks and emptyShelf. The complete project is on GitHub.

3.1. Domain Objects as Typed Inputs and Outputs
Domain objects are the data that flows between actions. We write them as Java records, and Embabel generates a JSON schema from their fields, including any @JsonPropertyDescription.
public record ReadingRequest(
@JsonPropertyDescription("first name of the reader") String reader,
@JsonPropertyDescription("book genre in lowercase, for example fantasy") String genre,
@JsonPropertyDescription("number of days the reader has") int days) {
}
public record Book(String title, int pages) {
}
public record Shelf(String genre, List<Book> books) {
public Shelf {
books = books == null ? List.of() : List.copyOf(books); // never null
}
public boolean isEmpty() {
return books.isEmpty();
}
}
public sealed interface ReadingAdvice permits ReadingPlan, NoBooksFound {
String reader();
}
public record ReadingPlan(String reader, String title, int pagesPerDay, String note)
implements ReadingAdvice {
}
public record NoBooksFound(String reader, String genre) implements ReadingAdvice {
}
The two goal types share the sealed interface ReadingAdvice. Embabel wants a distinct return type for each @AchievesGoal action, and the common interface gives the caller one type to ask for.
3.2. An Action That Calls the LLM
The first action gets the UserInput that the caller puts on the blackboard. Embabel also passes an OperationContext, and context.ai() returns a PromptRunner for the LLM call. Its createObject() method sends the prompt with the JSON schema of ReadingRequest and parses the answer into the record, with retries when the JSON doesn’t parse.
@Agent(description = "Creates a reading plan from a reader's message and the books in the library")
public class ReadingPlanAgent {
private final LibraryCatalog catalog;
public ReadingPlanAgent(LibraryCatalog catalog) {
this.catalog = catalog;
}
@Action(description = "Extract the reader, the genre and the number of days")
public ReadingRequest extractRequest(UserInput userInput, OperationContext context) {
return context.ai()
.withLlm(LlmOptions.withDefaultLlm().withTemperature(0.0))
.createObject("""
Extract the reader's first name, the book genre and the number of days
from this message. Write the genre in lowercase.
Message: %s
""".formatted(userInput.getContent()), ReadingRequest.class);
}
The @Agent annotation is meta-annotated with @Component, so Spring registers the class as a bean and injects LibraryCatalog. Compared with asking Spring AI for structured output by hand, no workflow code connects extractRequest() to the next step. The parameter UserInput is the precondition, and the return type ReadingRequest is the effect.
3.3. An Action Without an LLM
Not every step needs a model. Looking up books is a normal service call, and we keep it in plain Java because it costs nothing and always gives the same answer.
@Action(description = "Find the library books of the requested genre",
post = {"hasBooks", "emptyShelf"})
public Shelf findBooks(ReadingRequest request) {
return catalog.shelfFor(request.genre());
}
@Condition(name = "hasBooks")
public boolean hasBooks(Shelf shelf) {
return !shelf.isEmpty();
}
@Condition(name = "emptyShelf")
public boolean emptyShelf(Shelf shelf) {
return shelf.isEmpty();
}
A @Condition method returns a boolean and takes domain objects as parameters. Until a Shelf exists, both conditions are false, and Embabel evaluates them again after each action, so they must not change any state.
The planner only plans through a condition when some action declares it in post. Without post, no action can make hasBooks true, and the process ends as stuck before the first LLM call. With post, the planner assumes findBooks() can produce either condition and checks the real value after the action runs.
3.4. Two Goals and Giving the LLM a Tool
The goal actions finish the run. The pre attribute names the condition each one needs, so only one of them can run for a given shelf.
@AchievesGoal(description = "A reading plan has been created for the reader")
@Action(pre = "hasBooks", description = "Pick one book and plan the pages per day")
public ReadingPlan planReading(ReadingRequest request, Shelf shelf, OperationContext context) {
String books = shelf.books().stream()
.map(book -> "- " + book.title() + " (" + book.pages() + " pages)")
.collect(Collectors.joining("\n"));
return context.ai()
.withLlm(LlmOptions.withDefaultLlm().withTemperature(0.2))
.withToolObject(new ReadingTools())
.createObject("""
%s wants to finish one %s book in %d days.
Pick one book from this list:
%s
Pick the book that is easiest to finish in time.
Call the calculateDailyPages tool with the book's pages and the days.
Put a one-sentence note that encourages the reader in the note field.
""".formatted(request.reader(), request.genre(), request.days(), books),
ReadingPlan.class);
}
@AchievesGoal(description = "The reader was told that the genre has no books")
@Action(pre = "emptyShelf", description = "Report that the genre has no books")
public NoBooksFound noBooksFound(ReadingRequest request, Shelf shelf) {
return new NoBooksFound(request.reader(), shelf.genre());
}
}
Small models are bad at arithmetic, so we give the model a tool. A tool is any method annotated with @LlmTool, and withToolObject() offers it to this one LLM call. The idea is the same as function calling in Spring AI, and Embabel runs the tool loop.
public class ReadingTools {
@LlmTool(description = "Calculates how many pages per day are needed to finish a book in the given number of days")
public int calculateDailyPages(
@LlmTool.Param(description = "total pages of the book") int pages,
@LlmTool.Param(description = "number of days to finish the book") int days) {
if (pages <= 0 || days <= 0) {
return 0; // the LLM can send anything, so we check it
}
return (pages + days - 1) / days;
}
}
ReadingTools tools = new ReadingTools();
int hobbit = tools.calculateDailyPages(310, 10); // 31
int mistborn = tools.calculateDailyPages(541, 10); // 55
int invalid = tools.calculateDailyPages(310, 0); // 0
The tool name comes from the method name, and the model sees the descriptions of the method and its parameters. The name must not clash with a field of the output type. When the tool was called pagesPerDay, like the field in ReadingPlan, qwen2.5:7b kept calling it with the finished JSON as arguments.
4. Running the Agent From a REST Endpoint
The AgentInvocation API puts the starting objects on a new blackboard, finds the agent with a goal of the requested type and returns the last object of that type.
The invoke() method of AgentInvocation waits without a time limit and throws a NullPointerException when the process ends without a result. The safe version calls runAsync() with a timeout and checks the process status. A @RestController exposes the agent.
@PostMapping("/reading-plans")
public ResponseEntity<ReadingAdvice> plan(@RequestBody(required = false) String message)
throws Exception {
if (message == null || message.isBlank()) {
return ResponseEntity.badRequest().build(); // 400
}
AgentInvocation<ReadingAdvice> invocation =
AgentInvocation.create(agentPlatform, ReadingAdvice.class);
AgentProcess process = invocation.runAsync(new UserInput(message.strip()))
.get(5, TimeUnit.MINUTES); // TimeoutException after 5 min
if (process.getStatus() != AgentProcessStatusCode.COMPLETED) {
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build(); // 422, e.g. STUCK
}
return ResponseEntity.ok(process.last(ReadingAdvice.class)); // 200
}
We start the app with mvn spring-boot:run and send one message for a genre the library has and one for a genre it doesn’t have.
curl -X POST localhost:8080/reading-plans -H 'Content-Type: text/plain' \
-d 'Hi, I am Lokesh. I want to read a fantasy book in 10 days.'
curl -X POST localhost:8080/reading-plans -H 'Content-Type: text/plain' \
-d 'Alex here. Any poetry I can finish in 7 days?'
{"reader":"Lokesh","title":"The Hobbit","pagesPerDay":31,"note":"You've got this, Lokesh! The Hobbit is a bit easier to tackle with just 31 pages per day compared to Mistborn's 55 pages per day."}
{"reader":"Alex","genre":"poetry"}
The model called the tool for both fantasy books and picked The Hobbit at 31 pages per day instead of Mistborn at 55. The poetry message went to the other goal, so Alex gets a typed NoBooksFound instead of an invented book.
4.1. Reading the Planner Log
Embabel logs every plan it forms and every tool call. The trimmed log of the fantasy request shows the plan getting shorter as objects land on the blackboard.
[hungry_turing] formulated plan:
ReadingPlanAgent.extractRequest -> ReadingPlanAgent.findBooks -> ReadingPlanAgent.planReading
[hungry_turing] executing action ReadingPlanAgent.extractRequest
[hungry_turing] object bound it:ReadingRequest
[hungry_turing] executed action ReadingPlanAgent.extractRequest in PT12.41S
[hungry_turing] formulated plan:
ReadingPlanAgent.findBooks -> ReadingPlanAgent.planReading
[hungry_turing] object bound it:Shelf
[hungry_turing] executed action ReadingPlanAgent.findBooks in PT0S
[hungry_turing] formulated plan:
ReadingPlanAgent.planReading
[hungry_turing] (planReading) calling tool calculateDailyPages({"pages":310,"days":10})
[hungry_turing] (planReading) tool calculateDailyPages returned 31 in 5ms with payload {"pages":310,"days":10}
[hungry_turing] (planReading) calling tool calculateDailyPages({"days":10,"pages":541})
[hungry_turing] (planReading) tool calculateDailyPages returned 55 in 1ms with payload {"days":10,"pages":541}
[hungry_turing] object bound it:ReadingPlan
[hungry_turing] completed in PT1M31.0851313S
The poetry request starts with the same plan, because before findBooks() runs the planner can’t know the shelf will be empty. After the Shelf is bound, emptyShelf is true, and the next plan contains only noBooksFound(). That run made one LLM call and completed in 12.7 seconds.

4.2. Common Problems With Embabel and Local Models
Most problems in a first Embabel app come either from the plan or from a small model.
| Symptom | Cause | Fix |
|---|---|---|
| Log says “stuck” before any action, invoke() throws NullPointerException | A condition in pre is never declared as an effect | Add the condition to post of the action that produces its input |
| “No agent with outputClass … found” | No @AchievesGoal action returns the requested type | Ask for a type that a goal action returns, or a supertype of it |
| “Could not parse the given text to the desired target type” | The model answered in prose after the tool call | Use a larger model, or lower max-attempts to fail sooner |
| The model calls the tool with the output JSON as arguments | The tool name matches a field of the output type | Rename the tool, for example to calculateDailyPages |
| Requests end with a timeout | The default LLM timeout is 60 seconds | Raise embabel.agent.platform.llm-operations.prompts.default-timeout |
With qwen2.5:3b, the model answered with a sentence instead of JSON after the tool call in every retry, and qwen2.5:7b fixed it. The Embabel guide lists more settings for smaller and local models.
5. Testing Embabel Agents Without an API Key
LLM answers change from run to run and cost money, so our tests must not call a real model. Embabel replaces the LLM at two levels. The FakeOperationContext class from embabel-agent-api serves unit tests, and the EmbabelMockitoIntegrationTest base class from embabel-agent-test serves full flows.
| What we test | Embabel helper | Spring context | What it proves |
|---|---|---|---|
| One action and its prompt | FakeOperationContext, FakePromptRunner | No | The prompt, the LLM options and the tools of one call |
| A plain Java action or condition | None, call the method | No | Business logic of the step |
| The whole agent and the plan | EmbabelMockitoIntegrationTest | Yes | The planner picks the right actions and goal |
5.1. Unit Testing an Action With FakeOperationContext
An Embabel agent is a POJO, so we create it with new and call an action like any method. We pass a FakeOperationContext with a queued answer from expectResponse() and inspect the recorded calls afterwards.
private final ReadingPlanAgent agent = new ReadingPlanAgent(new LibraryCatalog());
@Test
void planReadingGivesTheLlmTheShelfAndTheTool() {
var context = FakeOperationContext.create();
var expected = new ReadingPlan("Lokesh", "The Hobbit", 31, "You can do it!");
context.expectResponse(expected); // fake LLM answer
var request = new ReadingRequest("Lokesh", "fantasy", 10);
Shelf shelf = new LibraryCatalog().shelfFor("fantasy");
ReadingPlan plan = agent.planReading(request, shelf, context);
assertThat(plan).isEqualTo(expected);
LlmInvocation call = context.getLlmInvocations().getFirst();
assertThat(call.getPrompt()).contains("The Hobbit (310 pages)", "Mistborn (541 pages)");
assertThat(call.getInteraction().getTools())
.extracting(tool -> tool.getDefinition().getName())
.containsExactly("calculateDailyPages");
}
The fake returns whatever we queued, so the value of the test is in the second half, which checks that the prompt contains the shelf and that the call offers the calculateDailyPages tool. The same class checks the temperature of extractRequest() with call.getInteraction().getLlm().getTemperature().
5.2. Testing the Whole Flow With EmbabelMockitoIntegrationTest
A unit test can’t show that the planner picks the right goal. For that, we extend EmbabelMockitoIntegrationTest, a @SpringBootTest base class that starts the real AgentPlatform and replaces the LLM layer with a Mockito mock.
class ReadingPlanAgentIntegrationTest extends EmbabelMockitoIntegrationTest {
@Test
void plansAFantasyBook() {
whenCreateObject(prompt -> prompt.contains("Extract the reader"), ReadingRequest.class)
.thenReturn(new ReadingRequest("Lokesh", "fantasy", 10));
whenCreateObject(prompt -> prompt.contains("Pick one book"), ReadingPlan.class)
.thenReturn(new ReadingPlan("Lokesh", "The Hobbit", 31, "Enjoy the trip to Middle-earth!"));
ReadingAdvice advice = AgentInvocation.create(agentPlatform, ReadingAdvice.class)
.invoke(new UserInput("I am Lokesh. A fantasy book in 10 days, please."));
assertThat(advice).isEqualTo(
new ReadingPlan("Lokesh", "The Hobbit", 31, "Enjoy the trip to Middle-earth!"));
verifyCreateObjectMatching(prompt -> prompt.contains("The Hobbit (310 pages)"),
ReadingPlan.class, llm -> llm.getTools().size() == 1);
}
@Test
void takesTheOtherGoalWhenTheShelfIsEmpty() {
whenCreateObject(prompt -> prompt.contains("Extract the reader"), ReadingRequest.class)
.thenReturn(new ReadingRequest("Alex", "poetry", 7));
ReadingAdvice advice = AgentInvocation.create(agentPlatform, ReadingAdvice.class)
.invoke(new UserInput("Alex here, any poetry for next week?"));
assertThat(advice).isEqualTo(new NoBooksFound("Alex", "poetry"));
verifyCreateObject(prompt -> prompt.contains("Extract the reader"), ReadingRequest.class);
verifyNoMoreInteractions(); // no second LLM call
}
}
The whenCreateObject() method stubs an LLM call by prompt text and output class. In the second test, verifyNoMoreInteractions() fails if the agent asks the LLM for a reading plan when the shelf is empty.
With Ollama stopped and no API key in the environment, the whole suite passes.
OllamaModelsConfig - No Ollama models discovered from default instance at http://localhost:11434. Check server configuration.
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in com.howtodoinjava.embabel.agent.ReadingPlanAgentIntegrationTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in com.howtodoinjava.embabel.agent.ReadingToolsTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 -- in com.howtodoinjava.embabel.agent.ReadingPlanAgentTest
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
6. Conclusion
Embabel lets us write an LLM agent as a Spring bean with @Action methods. The parameter and return types tell the GOAP planner what each step needs and produces, so the planner builds the flow at runtime and plans again after every action. Conditions add branches, and @AchievesGoal marks where a run ends.
Compared with Spring AI alone, Embabel removes the orchestration code once a feature has several steps. Compared with LangChain4j’s agentic workflows, nobody wires the order by hand, and the planner is an algorithm instead of an LLM.
For a first app, the Ollama starter and a 7B model run everything on our own machine. We keep deterministic steps in plain Java, and we test prompts with FakeOperationContext and the whole plan with EmbabelMockitoIntegrationTest.
7. References
- Embabel Agent Framework User Guide 1.5.2
- Embabel Agent API Docs 1.5.2
- embabel-agent-starter-ollama on Maven Central
- Spring AI ChatClient API
- Spring AI Tool Calling
- LangChain4j Agents and Agentic AI
- Ollama Docker image
- Embabel Agent Framework Reaches 1.0 (InfoQ)
Happy Learning !!