
Embabel Agent Release: 0.3.1
© 2024-2025 Embabel
1. Overview
Agentic AI is the use of large language (and other multi-modal) models not just to generate text, but to act as reasoning, goal-driven agents that can plan, call tools, and adapt their actions to deliver outcomes.
The JVM is a compelling platform for this because its strong type safety provides guardrails for integrating LLM-driven behaviors with real systems. Because so many production applications already run on the JVM it is the natural place to embed AI.
While Agentic AI has been hyped, much of it has lived in academic demos with little practical value; by integrating directly into legacy and enterprise JVM applications, we can unlock AI capabilities without rewriting core systems or tearing down a factory to install new machinery.
1.1. Glossary
Before we begin, in this glossary we’ll explain some terms that may be new if you’re taking your first steps as an applied AI software developer. It is assumed that you already know what a large language model (LLM) is from an end-user’s point of view.
| You may skim or skip this section if you’re already a seasoned agentic AI engineer. |
- Agent
-
An Agent in the Embabel framework is a self-contained component that bundles together domain logic, AI capabilities, and tool usage to achieve a specific goal on behalf of the user.
Inside, it exposes multiple
@Actionmethods, each representing discrete steps the agent can take. Actions depend on typically structured (sometimes natural language) input. The input is used to perform tasks on behalf of the user - executing domain code, calling AI models or even calling other agents as a sub-process.When an AI model is called it may be given access to tools that expand its capabilities in order to achieve a goal. The output is a new type, representing a transformation of the input, however during execution one or more side-effects can occur. An example of side effects might be new records stored in a database, orders placed on an e-commerce site and so on.
- Tools
-
Tools extend the raw capabilities of an LLM by letting it interact with the outside world. On its own, a language model can only generate responses from its training data and context window, which risks producing inaccurate or “hallucinated” answers.
While tool usage is inspired by an technique known as ReAct (Reason + Act), which itself builds on Chain of Thought reasoning, most recent LLMs allow specifying tools specifically instead of relying on prompt engineering techniques.
When tools are present, the LLM interprets the user request, plans steps, and then delegates certain tasks to tools in a loop. This lets the model alternate between reasoning (“what needs to be done?”) and acting (“which tool can do it?”).
Benefits of tools include:
-
The ability to answer questions or perform tasks beyond what the LLM was trained on, by delegating to domain-specific or external systems.
-
Producing useful side effects, such as creating database records, generating visualizations, booking flights, or invoking any process the system designer provides.
In short, tools are one way to bridge the gap between text prediction and real-world action, turning an LLM into a practical agent capable of both reasoning and execution. In Embabel many tools are bound domain objects.
-
- MCP
-
Model Context Protocol (MCP) is a standardized way of hosting and sharing tools. Unlike plain tools, which are usually wired directly into one agent or app, an MCP Server makes tools discoverable and reusable across models and runtimes they can be registered system-wide or at runtime, and invoked through a common protocol. Embabel can both consume and publish such tools for systems integration.
- Domain Integrated Context Engineering (DICE)
-
Enhances context engineering by grounding both LLM inputs and outputs in typed domain objects. Instead of untyped prompts, context is structured with business-aware models that provide precision, testability, and seamless integration with existing systems. DICE transforms context into a re-usable, inspectable, and reliably manipulable artifact.
1.2. Why do we need an Agent Framework?
Aren’t LLMs smart enough to solve our problems directly? Aren’t MCP tools all we need to allow them to solve complex problems? LLMs seem to get more capable by the day and MCPs can give LLMs access to a lot of empowering tools, making them even more capable.
But there are still many reasons that a higher level orchestration technology is needed, especially for business applications. Here are some of the most important:
-
Explainability: Why were choices made in solving a problem?
-
Discoverability: How do we find the right tools at each point, and ensure that models aren’t confused in choosing between them?
-
Ability to mix models, so that we are not reliant only on the largest models but can use local, cheaper, private models for many tasks
-
Ability to inject guardrails at any point in a flow
-
Ability to manage flow execution and introduce greater resilience
-
Composability of flows at scale. We’ll soon be seeing not just agents running on one system, but federations of agents.
-
Safer integration with sensitive existing systems such as databases, where it is dangerous to allow even the best LLM write access.
Agent frameworks break complex tasks into smaller, manageable components, offering greater control and predictability.
Agent frameworks offer "code agency" as well as "LLM agency." This division is well described in this paper from NVIDIA Research.
Further reading:
1.3. Embabel Differentiators
So how does Embabel differ from other agent frameworks? We like to believe the Embabel agent framework is to be the best fit for developing agentic AI in the enterprise.
1.3.1. Sophisticated Planning
Goes beyond a finite state machine or sequential execution with nesting by introducing a true planning step, using a non-LLM AI algorithm. This enables the system to perform tasks it wasn’t programmed to do by combining known steps in a novel order, as well as make decisions about parallelization and other runtime behavior.
1.3.2. Superior Extensibility and Reuse
Because of dynamic planning, adding more domain objects, actions, goals and conditions can extend the capability of the system, without editing FSM definitions or existing code.
1.3.3. Strong Typing and Object Orientation
Actions, goals and conditions are informed by a domain model, which can include behavior. Everything is strongly typed and prompts and manually authored code interact cleanly. No more magic maps. Enjoy full refactoring support.
1.3.4. Platform Abstraction
Clean separation between programming model and platform internals allows running locally while potentially offering higher QoS in production without changing application code.
1.3.5. LLM Mixing
It is easy to build applications that mix LLMs, ensuring the most cost-effective yet capable solution. This enables the system to leverage the strengths of different models for different tasks. In particular, it facilitates the use of local models for point tasks. This can be important for cost and privacy.
1.3.6. Spring and JVM Integration
Built on Spring and the JVM, making it easy to access existing enterprise functionality and capabilities. For example:
-
Spring can inject and manage agents, including using Spring AOP to decorate functions.
-
Robust persistence and transaction management solutions are available.
1.4. Core Concepts
Agent frameworks break up tasks into separate smaller interactions, making LLM use more predictable and focused.
Embabel models agentic flows in terms of:
-
Actions: Steps an agent takes. These are the building blocks of agent behavior.
-
Goals: What an agent is trying to achieve.
-
Conditions: Conditions to do evaluations while planning. Conditions are reassessed after each action is executed.
-
Domain Model: Objects underpinning the flow and informing Actions, Goals and Conditions.
This enables Embabel to create a plan: A sequence of actions to achieve a goal. Plans are dynamically formulated by the system, not the programmer. The system replans after the completion of each action, allowing it to adapt to new information as well as observe the effects of the previous action. This is effectively an OODA loop.
| Application developers don’t usually have to deal with conditions and planning directly, as most conditions result from data flow defined in code, allowing the system to infer pre and post conditions to (re-)evaluate the plan. |
1.4.1. Complete Example
Let’s look at a complete example that demonstrates how Embabel infers conditions from input/output types and manages data flow between actions. This example comes from the Embabel Agent Examples repository:
@Agent(description = "Find news based on a person's star sign") (1)
public class StarNewsFinder {
private final HoroscopeService horoscopeService; (2)
private final int storyCount;
public StarNewsFinder(
HoroscopeService horoscopeService, (3)
@Value("${star-news-finder.story.count:5}") int storyCount) {
this.horoscopeService = horoscopeService;
this.storyCount = storyCount;
}
@Action (4)
public StarPerson extractStarPerson(UserInput userInput, OperationContext context) { (5)
return context.ai()
.withLlm(OpenAiModels.GPT_41)
.createObject("""
Create a person from this user input, extracting their name and star sign:
%s""".formatted(userInput.getContent()), StarPerson.class); (6)
}
@Action (7)
public Horoscope retrieveHoroscope(StarPerson starPerson) { (8)
// Uses regular injected Spring service - not LLM
return new Horoscope(horoscopeService.dailyHoroscope(starPerson.sign())); (9)
}
@Action(toolGroups = {CoreToolGroups.WEB}) (10)
public RelevantNewsStories findNewsStories(
StarPerson person, Horoscope horoscope, OperationContext context) { (11)
var prompt = """
%s is an astrology believer with the sign %s.
Their horoscope for today is: %s
Given this, use web tools to find %d relevant news stories.
""".formatted(person.name(), person.sign(), horoscope.summary(), storyCount);
return context.ai().withDefaultLlm().createObject(prompt, RelevantNewsStories.class); (12)
}
@AchievesGoal(description = "Write an amusing writeup based on horoscope and news") (13)
@Action
public Writeup writeup(
StarPerson person, RelevantNewsStories stories, Horoscope horoscope,
OperationContext context) { (14)
var llm = LlmOptions.fromCriteria(ModelSelectionCriteria.getAuto())
.withTemperature(0.9); (15)
var storiesFormatted = stories.items().stream()
.map(s -> "- " + s.url() + ": " + s.summary())
.collect(Collectors.joining("\n"));
var prompt = """
Write something amusing for %s based on their horoscope and news stories.
Format as Markdown with links.
<horoscope>%s</horoscope>
<news_stories>
%s
</news_stories>
""".formatted(person.name(), horoscope.summary(), storiesFormatted); (16)
return context.ai().withLlm(llm).createObject(prompt, Writeup.class); (17)
}
}
| 1 | Agent Declaration: The @Agent annotation defines this as an agent capable of a multi-step flow. |
| 2 | Spring Integration: Regular Spring dependency injection - the agent uses both LLM services and traditional business services. |
| 3 | Service Injection: HoroscopeService is injected like any Spring bean - agents can mix AI and non-AI operations seamlessly. |
| 4 | Action Definition: @Action marks methods as steps the agent can take.
Each action represents a capability. |
| 5 | Input Condition Inference: The method signature extractStarPerson(UserInput userInput, …) tells Embabel:
|
| 6 | Output Condition Creation: Returning StarPerson creates:
|
| 7 | Non-LLM Action: Not all actions use LLMs - this demonstrates hybrid AI/traditional programming. |
| 8 | Data Flow Chain: The method signature retrieveHoroscope(StarPerson starPerson) creates:
|
| 9 | Regular Service Call: This action calls a traditional Spring service - demonstrating how agents blend AI and conventional operations. |
| 10 | Tool Requirements: toolGroups = {CoreToolGroups.WEB} specifies this action needs web search capabilities. |
| 11 | Multi-Input Dependencies: This method requires both StarPerson and Horoscope - showing complex data flow orchestration. |
| 12 | Tool-Enabled LLM: The LLM can use web tools to search for current news stories based on the horoscope context. |
| 13 | Goal Achievement: @AchievesGoal marks this as a terminal action that completes the agent’s objective. |
| 14 | Complex Input Requirements: The final action requires three different data types, showing sophisticated orchestration. |
| 15 | Creative Configuration: High temperature (0.9) optimizes for creative, entertaining output - appropriate for amusing writeups. |
| 16 | Structured Prompt with Data: The prompt includes both the horoscope summary and formatted news stories using XML-style tags. This ensures the LLM has all the context it needs from earlier actions. |
| 17 | Final Output: Returns Writeup, completing the agent’s goal with personalized content. |
State is managed by the framework, through the process blackboard.
1.4.2. The Inferred Execution Plan for the Example
Based on the type signatures alone, Embabel automatically infers this execution plan for the example agent above:
Goal: Produce a Writeup (final return type of @AchievesGoal action)
The initial plan:
-
To emit
Writeup→ needwriteup()action -
writeup()requiresStarPerson,RelevantNewsStories, andHoroscope -
To get
StarPerson→ needextractStarPerson()action -
To get
Horoscope→ needretrieveHoroscope()action (requiresStarPerson) -
To get
RelevantNewsStories→ needfindNewsStories()action (requiresStarPersonandHoroscope) -
extractStarPerson()requiresUserInput→ must be provided by user
Execution sequence:
UserInput → extractStarPerson() → StarPerson → retrieveHoroscope() → Horoscope → findNewsStories() → RelevantNewsStories → writeup() → Writeup and achieves goal.
1.4.3. Key Benefits of Type-Driven Flow
Automatic Orchestration: No manual workflow definition needed - the agent figures out the sequence from type dependencies. This is particularly beneficial if things go wrong, as the planner can re-evaluate the situation and may be able to find an alternative path to the goal.
Dynamic Replanning: After each action, the agent reassesses what’s possible based on available data objects.
Type Safety: Compile-time guarantees that data flows correctly between actions. No magic string keys.
Flexible Execution: If multiple actions could produce the required input type, the agent chooses based on context and efficiency. (Actions can have cost and value.)
This demonstrates how Embabel transforms simple method signatures into sophisticated multi-step agent behavior, with the complex orchestration handled automatically by the framework.
2. Getting Started
2.1. Quickstart
There are two GitHub template repos you can use to create your own project:
-
Java template - github.com/embabel/java-agent-template
-
Kotlin template - github.com/embabel/kotlin-agent-template
Or you can use our project creator to create a custom project:
uvx --from git+https://github.com/embabel/project-creator.git project-creator
The uvx command can be installed from the astral-uv package. It is a Python package and project manager used to run the Embabel project creator scripts.
|
2.2. Getting the Binaries
The easiest way to get started with Embabel Agent is to add the Spring Boot starter dependency to your project. Embabel release binaries are published to Maven Central.
2.2.1. Build Configuration
Add the appropriate Embabel Agent Spring Boot starter to your build file depending on your choice of application type:
Shell Starter
Starts the application in console mode with an interactive shell powered by Embabel.
- Maven (pom.xml)
-
<dependency> <groupId>com.embabel.agent</groupId> <artifactId>embabel-agent-starter-shell</artifactId> <version>${embabel-agent.version}</version> </dependency> - Gradle Kotlin DSL (build.gradle.kts)
-
dependencies { implementation("com.embabel.agent:embabel-agent-starter-shell:${embabel-agent.version}") } - Gradle Groovy DSL (build.gradle)
-
dependencies { implementation 'com.embabel.agent:embabel-agent-starter-shell:${embabel-agent.version}' }
Features:
-
✅ Interactive command-line interface
-
✅ Agent discovery and registration
-
✅ Human-in-the-loop capabilities
-
✅ Progress tracking and logging
-
✅ Development-friendly error handling
MCP Server Starter
Starts the application with HTTP listener where agents are autodiscovered and registered as MCP servers, available for integration via SSE, Streamable-HTTP or Stateless Streamable-HTTP protocols.
- Maven (pom.xml)
-
<dependency> <groupId>com.embabel.agent</groupId> <artifactId>embabel-agent-starter-mcpserver</artifactId> <version>${embabel-agent.version}</version> </dependency> - Gradle Kotlin DSL (build.gradle.kts)
-
dependencies { implementation("com.embabel.agent:embabel-agent-starter-mcpserver:${embabel-agent.version}") } - Gradle Groovy DSL (build.gradle)
-
dependencies { implementation 'com.embabel.agent:embabel-agent-starter-mcpserver:${embabel-agent.version}' }
Features:
-
✅️ MCP protocol server implementation
-
✅️ Tool registration and discovery
-
✅️ JSON-RPC communication via SSE (Server-Sent Events), Streamable-HTTP or Stateless Streamable-HTTP
-
✅️ Integration with MCP-compatible clients
-
✅️ Security and sandboxing
Basic Agent Platform Starter
Initializes Embabel Agent Platform in the Spring Container. Platform beans are available via Spring Dependency Injection mechanism. Application startup mode (web, console, microservice, etc.) is determined by the Application Designer.
- Maven (pom.xml)
-
<dependency> <groupId>com.embabel.agent</groupId> <artifactId>embabel-agent-starter</artifactId> <version>${embabel-agent.version}</version> </dependency> - Gradle Kotlin DSL (build.gradle.kts)
-
dependencies { implementation("com.embabel.agent:embabel-agent-starter:${embabel-agent.version}") } - Gradle Groovy DSL (build.gradle)
-
dependencies { implementation 'com.embabel.agent:embabel-agent-starter:${embabel-agent.version}' }
Features:
-
✅️ Application decides on startup mode (console, web application, etc)
-
✅️ Agent discovery and registration
-
✅️ Agent Platform beans available via Dependency Injection mechanism
-
✅️ Progress tracking and logging
-
✅️ Development-friendly error handling
Embabel Snapshots
If you want to use Embabel snapshots, you’ll need to add the Embabel repository to your build.
- Maven (pom.xml)
-
<repositories> <repository> <id>embabel-releases</id> <url>https://repo.embabel.com/artifactory/libs-release</url> <releases> <enabled>true</enabled> </releases> <snapshots> <enabled>false</enabled> </snapshots> </repository> <repository> <id>embabel-snapshots</id> <url>https://repo.embabel.com/artifactory/libs-snapshot</url> <releases> <enabled>false</enabled> </releases> <snapshots> <enabled>true</enabled> </snapshots> </repository> <repository> <id>spring-milestones</id> <url>https://repo.spring.io/milestone</url> <snapshots> <enabled>false</enabled> </snapshots> </repository> </repositories> - Gradle Kotlin DSL (build.gradle.kts)
-
repositories { mavenCentral() maven { name = "embabel-releases" url = uri("https://repo.embabel.com/artifactory/libs-release") mavenContent { releasesOnly() } } maven { name = "embabel-snapshots" url = uri("https://repo.embabel.com/artifactory/libs-snapshot") mavenContent { snapshotsOnly() } } maven { name = "Spring Milestones" url = uri("https://repo.spring.io/milestone") } } - Gradle Groovy DSL (build.gradle)
-
repositories { mavenCentral() maven { name = 'embabel-releases' url = 'https://repo.embabel.com/artifactory/libs-release' mavenContent { releasesOnly() } } maven { name = 'embabel-snapshots' url = 'https://repo.embabel.com/artifactory/libs-snapshot' mavenContent { snapshotsOnly() } } maven { name = 'Spring Milestones' url = 'https://repo.spring.io/milestone' } } dependencies { implementation 'com.embabel.agent:embabel-agent-starter-shell:${embabel-agent.version}' }
2.2.2. Environment Setup
Before running your application, you’ll need to set up your environment with API keys for the LLM providers you plan to use.
Example .env file:
OPENAI_API_KEY=your_openai_api_key_here ANTHROPIC_API_KEY=your_anthropic_api_key_here GEMINI_API_KEY=your_gemini_api_key_here MISTRAL_API_KEY=your_mistral_api_key_here
OpenAI Compatible (GPT-4, GPT-5, etc.)
-
Required:
-
OPENAI_API_KEY: API key for OpenAI or compatible services (e.g., Azure OpenAI, etc.)
-
-
Optional:
-
OPENAI_BASE_URL: base URL of the OpenAI deployment (for Azure AI use{resource-name}.openai.azure.com/openai) -
OPENAI_COMPLETIONS_PATH: custom path for completions endpoint (default:/v1/completions) -
OPENAI_EMBEDDINGS_PATH: custom path for embeddings endpoint (default:/v1/embeddings)
-
DeepSeek
-
Required:
-
DEEPSEEK_API_KEY: API key for DeepSeek services
-
-
Optional:
-
DEEPSEEK_BASE_URL: base URL for DeepSeek API (default:api.deepseek.com)
-
Google Gemini (OpenAI Compatible)
Uses the OpenAI-compatible endpoint for Gemini models.
-
Required:
-
GEMINI_API_KEY: API key for Google Gemini services
-
-
Optional:
-
GEMINI_BASE_URL: base URL for Gemini API (default:generativelanguage.googleapis.com)
-
Google GenAI (Native)
Uses the native Google GenAI SDK for direct access to Gemini models with full feature support including thinking mode.
-
Required (API Key authentication):
-
GOOGLE_API_KEY: API key for Google AI Studio
-
-
Required (Vertex AI authentication - alternative to API key):
-
GOOGLE_PROJECT_ID: Google Cloud project ID -
GOOGLE_LOCATION: Google Cloud region (e.g.,us-central1)
-
| Use API key authentication for Google AI Studio, or Vertex AI authentication for Google Cloud deployments. Vertex AI authentication requires Application Default Credentials (ADC) to be configured. |
Gemini 3 models are only available in the global location on Vertex AI.
To use Gemini 3 with Vertex AI, you must set GOOGLE_LOCATION=global.
|
To add Google GenAI support to your project:
<dependency>
<groupId>com.embabel.agent</groupId>
<artifactId>embabel-agent-starter-google-genai</artifactId>
<version>${embabel-agent.version}</version>
</dependency>
Available models include:
-
gemini-3-pro-preview- Latest Gemini 3 Pro preview with advanced reasoning -
gemini-2.5-pro- High-performance model with thinking support -
gemini-2.5-flash- Best price-performance model -
gemini-2.5-flash-lite- Cost-effective high-throughput model -
gemini-2.0-flash- Fast and efficient -
gemini-2.0-flash-lite- Lightweight version
Example configuration in application.yml:
embabel:
models:
default-llm: gemini-2.5-flash (1)
llms:
fast: gemini-2.5-flash
best: gemini-2.5-pro
reasoning: gemini-3-pro-preview
agent:
platform:
models:
googlegenai: (2)
api-key: ${GOOGLE_API_KEY} (3)
# Or use Vertex AI authentication:
# project-id: ${GOOGLE_PROJECT_ID}
# location: ${GOOGLE_LOCATION}
max-attempts: 10
backoff-millis: 5000
| 1 | Set a Google GenAI model as the default LLM |
| 2 | Google GenAI specific configuration |
| 3 | API key can be set here or via environment variable GOOGLE_API_KEY |
Mistral AI
-
Required:
-
MISTRAL_API_KEY: API key for Mistral AI services
-
-
Optional:
-
MISTRAL_BASE_URL: base URL for Mistral AI API (default:api.mistral.ai)
-
2.3. Getting Embabel Running
2.3.1. Running the Examples
The quickest way to get started with Embabel is to run the examples:
# Clone and run examples
git clone https://github.com/embabel/embabel-agent-examples
cd embabel-agent-examples/scripts/java
./shell.sh
Choose the java or kotlin scripts directory depending on your preference.
|
2.3.2. Prerequisites
-
Java 21+
-
API Key from OpenAI, Anthropic, or Google
-
Maven 3.9+ (optional)
Set your API keys:
export OPENAI_API_KEY="your_openai_key"
export ANTHROPIC_API_KEY="your_anthropic_key"
export GOOGLE_API_KEY="your_google_api_key"
For Google GenAI, you can use either GOOGLE_API_KEY (Google AI Studio) or Vertex AI authentication with GOOGLE_PROJECT_ID and GOOGLE_LOCATION.
|
2.3.3. Using the Shell
Spring Shell is an easy way to interact with the Embabel agent framework, especially during development.
Type help to see available commands.
Use execute or x to run an agent:
execute "Lynda is a Scorpio, find news for her" -p -r
This will look for an agent, choose the star finder agent and run the flow. -p will log prompts -r will log LLM responses.
Omit these for less verbose logging.
Options:
-
-plogs prompts -
-rlogs LLM responses
Use the chat command to enter an interactive chat with the agent.
It will attempt to run the most appropriate agent for each command.
Spring Shell supports history.
Type !! to repeat the last command.
This will survive restarts, so is handy when iterating on an agent.
|
2.3.4. Example Commands
Try these commands in the shell:
# Simple horoscope agent execute "My name is Sarah and I'm a Leo" # Research with web tools (requires Docker Desktop with MCP extension) execute "research the recent australian federal election. what is the position of the Greens party?" # Fact checking x "fact check the following: holden cars are still made in australia"
2.3.5. Implementing Your Own Shell Commands
Particularly during development, you may want to implement your own shell commands to try agents or flows. Simply write a Spring Shell component and Spring will inject it and register it automatically.
For example, you can inject the AgentPlatform and use it to invoke agents directly, as in this code from the examples repository:
@ShellComponent
public record SupportAgentShellCommands(
AgentPlatform agentPlatform
) {
@ShellMethod("Get bank support for a customer query")
public String bankSupport(
@ShellOption(value = "id", help = "customer id", defaultValue = "123") Long id,
@ShellOption(value = "query", help = "customer query", defaultValue = "What's my balance, including pending amounts?") String query
) {
var supportInput = new SupportInput(id, query);
System.out.println("Support input: " + supportInput);
var invocation = AgentInvocation
.builder(agentPlatform)
.options(ProcessOptions.builder().verbosity(v -> v.showPrompts(true)).build())
.build(SupportOutput.class);
var result = invocation.invoke(supportInput);
return result.toString();
}
}
2.4. Adding a Little AI to Your Application
Before we get into the magic of full-blown Embabel agents, let’s see how easy it is to add a little AI to your application using the Embabel framework. Sometimes this is all you need.
The simplest way to use Embabel is to inject an OperationContext and use its AI capabilities directly.
This approach is consistent with standard Spring dependency injection patterns.
package com.embabel.example.injection;
import com.embabel.agent.api.common.OperationContext;
import com.embabel.common.ai.model.LlmOptions;
import org.springframework.stereotype.Component;
/**
* Demonstrate the simplest use of Embabel's AI capabilities,
* injecting an AI helper into a Spring component.
* The jokes will be terrible, but don't blame Embabel, blame the LLM.
*/
@Component
public record InjectedComponent(Ai ai) {
public record Joke(String leadup, String punchline) {
}
public String tellJokeAbout(String topic) {
return ai
.withDefaultLlm()
.generateText("Tell me a joke about " + topic);
}
public Joke createJokeObjectAbout(String topic1, String topic2, String voice) {
return ai
.withLlm(LlmOptions.withDefaultLlm().withTemperature(.8))
.createObject("""
Tell me a joke about %s and %s.
The voice of the joke should be %s.
The joke should have a leadup and a punchline.
""".formatted(topic1, topic2, voice),
Joke.class);
}
}
This example demonstrates several key aspects of Embabel’s design philosophy:
-
Standard Spring Integration: The
Aiobject is injected like any other Spring dependency using constructor injection -
Simple API: Access AI capabilities through the
Aiinterface directly orOperationContext.ai(), which can also be injected in the same way -
Flexible Configuration: Configure LLM options like temperature on a per-call basis
-
Type Safety: Generate structured objects directly with
createObject()method -
Consistent Patterns: Works exactly like you’d expect any Spring component to work
The Ai type provides access to all of Embabel’s AI capabilities without requiring a full agent setup, making it perfect for adding AI features to existing applications incrementally.
The Ai and OperationContext` APIs are used throughout Embabel applications, as a convenient gateway to key AI and other functionality.
|
2.5. Writing Your First Agent
The easiest way to create your first agent is to use the Java or Kotlin template repositories.
2.5.1. Using the Template
Create a new project from the Java template or Kotlin template by clicking "Use this template" on GitHub.
Or use the project creator:
uvx --from git+https://github.com/embabel/project-creator.git project-creator
2.5.2. Example: WriteAndReviewAgent
The Java template includes a WriteAndReviewAgent that demonstrates key concepts:
@Agent(description = "Agent that writes and reviews stories")
public class WriteAndReviewAgent {
@Action
public Story writeStory(UserInput userInput, OperationContext context) {
return context.ai()
.withAutoLlm()
.createObject("""
You are a creative writer who aims to delight and surprise.
Write a story about %s
""".formatted(userInput.getContent()),
Story.class);
}
@AchievesGoal(description = "Review a story")
@Action
public ReviewedStory reviewStory(Story story, OperationContext context) {
return context.ai()
.withLlmByRole("reviewer")
.createObject("""
You are a meticulous editor.
Carefully review this story:
%s
""".formatted(story.text),
ReviewedStory.class);
}
}
2.5.3. Key Concepts Demonstrated
Multiple LLMs with Different Configurations:
-
Writer LLM uses high temperature (0.8) for creativity
-
Reviewer LLM uses low temperature (0.2) for analytical review
-
Different personas guide the model behavior
Actions and Goals:
-
@Actionmethods are the steps the agent can take -
@AchievesGoalmarks the final action that completes the agent’s work
Domain Objects:
-
StoryandReviewedStoryare strongly-typed domain objects -
Help structure the interaction between actions
2.5.4. Running Your Agent
Set your API keys and run the shell:
export OPENAI_API_KEY="your_key_here"
./scripts/shell.sh
In the shell, try:
x "Tell me a story about a robot learning to paint"
The agent will:
-
Generate a creative story using the writer LLM
-
Review and improve it using the reviewer LLM
-
Return the final reviewed story
2.5.5. Next Steps
-
Explore the examples repository for more complex agents
-
Read the Reference Documentation for detailed API information
-
Try building your own domain-specific agents
3. Reference
3.1. Invoking an Agent
Agents can be invoked programmatically or via user input.
See Invoking Embabel Agents for details on programmatic invocation. Programmatic invocation typically involves structured types other than user input.
In the case of user input, an LLM will choose the appropriate agent via the Autonomy class.
Behavior varies depending on configuration:
-
In closed mode, the LLM will select the agent based on the user input and the available agents in the system.
-
In open mode, the LLM will select the goal based on the user input and then assemble an agent that can achieve that goal from the present world state.
3.2. Agent Process Flow
When an agent is invoked, Embabel creates an AgentProcess with a unique identifier that manages the complete execution lifecycle.
3.2.1. AgentProcess Lifecycle
An AgentProcess maintains state throughout its execution and can transition between various states:
Process States:
-
NOT_STARTED: The process has not started yet -
RUNNING: The process is executing without any known problems -
COMPLETED: The process has completed successfully -
FAILED: The process has failed and cannot continue -
TERMINATED: The process was killed by an early termination policy -
KILLED: The process was killed by the user or platform -
STUCK: The process cannot formulate a plan to progress (may be temporary) -
WAITING: The process is waiting for user input or external event -
PAUSED: The process has paused due to scheduling policy
Process Execution Methods:
-
tick(): Perform the next single step and return when an action completes -
run(): Execute the process as far as possible until completion, failure, or a waiting state
These methods are not directly called by user code, but are managed by the framework to control execution flow.
Each AgentProcess maintains:
-
Unique ID: Persistent identifier for tracking and reference
-
History: Record of all executed actions with timing information
-
Goal: The objective the process is trying to achieve
-
Failure Info: Details about any failure that occurred
-
Parent ID: Reference to parent process for nested executions
3.2.2. Planning
Planning occurs after each action execution using Goal-Oriented Action Planning (GOAP). The planning process:
-
Analyze Current State: Examine the current blackboard contents and world state
-
Identify Available Actions: Find all actions that can be executed based on their preconditions
-
Search for Action Sequences: Use A* algorithm to find optimal paths to achieve the goal
-
Select Optimal Plan: Choose the best action sequence based on cost and success probability
-
Execute Next Action: Run the first action in the plan and replan
This creates a dynamic OODA loop (Observe-Orient-Decide-Act): - Observe: Check current blackboard state and action results - Orient: Understand what has changed since the last planning cycle - Decide: Formulate or update the plan based on new information - Act: Execute the next planned action
The replanning approach allows agents to:
-
Adapt to unexpected action results
-
Handle dynamic environments where conditions change
-
Recover from partial failures
-
Take advantage of new opportunities that arise
3.2.3. Blackboard
The Blackboard serves as the shared memory system that maintains state throughout the agent process execution. It implements the Blackboard architectural pattern, a knowledge-based system approach.
Most of the time, user code doesn’t need to interact with the blackboard directly, as it is managed by the framework. For example, action inputs come from the blackboard, and action outputs are automatically added to the blackboard, and conditions are evaluated based on its contents.
Key Characteristics:
-
Central Repository: Stores all domain objects, intermediate results, and process state
-
Type-Based Access: Objects are indexed and retrieved by their types
-
Ordered Storage: Objects maintain the order they were added, with latest being default
-
Immutable Objects: Once added, objects cannot be modified (new versions can be added)
-
Condition Tracking: Maintains boolean conditions used by the planning system
Core Operations:
// Add objects to blackboard (1)
blackboard += person
blackboard["result"] = analysis
// Retrieve objects by type
val person = blackboard.last<Person>()
val allPersons = blackboard.all<Person>()
// Check conditions
blackboard.setCondition("userVerified", true)
val verified = blackboard.getCondition("userVerified") (2)
// Hide an object
blackboard.hide(somethingWeDontWantToPlanOnLater) (3)
| 1 | Adding Objects: Objects are added to the blackboard automatically when returned from action methods, so you don’t typically need to call this API.
They can also be added manually using the += operator (Kotlin only) or set method with an optional key. |
| 2 | Conditions: Conditions are normally calculated in @Condition methods, so you don’t usually need to check or set them via the API. |
| 3 | Hiding Objects: Prevents an object from being considered in future planning cycles. For example, the object might be a command that we have handled. It will remain in the blackboard history but will not be available to planning or via the Blackboard API. |
Data Flow:
-
Input Processing: Initial user input is added to the blackboard
-
Action Execution: Each action reads inputs from blackboard and adds results
-
State Evolution: Blackboard accumulates objects representing the evolving state
-
Planning Input: Current blackboard state informs the next planning cycle
-
Result Extraction: Final results are retrieved from blackboard upon completion
The blackboard enables:
-
Loose Coupling: Actions don’t need direct references to each other
-
Flexible Data Flow: Actions can consume any available data of the right type
-
State Persistence: Complete execution history is maintained
-
Debugging Support: Full visibility into state evolution for troubleshooting
3.2.4. Binding
By default, items in the blackboard are matched by type. When there are multiple candidates of the same type, the most recently added one is provided. It is also possible to assign a specific name to blackboard items.
An example of explicit binding in an action method:
@Action
public Person extractPerson(UserInput userInput, OperationContext context) {
PersonImpl maybeAPerson = context.promptRunner().withLlm(LlmOptions.fromModel(OpenAiModels.GPT_41)).createObjectIfPossible(
"""
Create a person from this user input, extracting their name:
%s""".formatted(userInput.getContent()),
PersonImpl.class
);
if (maybeAPerson != null) {
context.bind("user", maybeAPerson); (1)
}
return maybeAPerson;
}
| 1 | Explicit binding to the blackboard. Not usually necessary as action method return values are automatically bound. |
The following example requires a Thing named thingOne to be present in the blackboard:
@Action
public Whatever doWithThing(
@RequireNameMatch Thing thingOne) { (1)
| 1 | The @RequireNameMatch annotation on the parameter specifies that the parameter should be matched by both type and name.
Multiple parameters can be so annotated. |
The following example uses @Action.outputBinding to cause a thingOne to be bound in the blackboard, satisfying the previous example:
@Action(outputBinding="thingOne")
public Thing bindThing1() { ...
| When routing flows by type, the name is not important, but for reference the default name is 'it'. |
3.2.5. Context
Embabel offers a way to store longer term state: the com.embabel.agent.core.Context.
While a blackboard is tied to a specific agent process, a context can persist across multiple processes.
Contexts are identified by a unique contextId string.
When starting an agent process, you can specify a contextId in the ProcessOptions.
This will populate that process’s blackboard with any data stored in the specified context.
Context persistence is dependent on the implementation of com.embabel.agent.spi.ContextRepository.
The default implementation works only in memory, so does not survive server restarts.
|
3.4. Domain Objects
Domain objects in Embabel are not just strongly-typed data structures - they are real objects with behavior that can be selectively exposed to LLMs and used in agent actions.
3.4.1. Objects with Behavior
Unlike simple structs or DTOs, Embabel domain objects can encapsulate business logic and expose it to LLMs through the @Tool annotation.
For example:
@Entity
public class Customer {
private String name;
private LoyaltyLevel loyaltyLevel;
private List<Order> orders;
@Tool(description = "Calculate the customer's loyalty discount percentage") (1)
public BigDecimal getLoyaltyDiscount() {
return loyaltyLevel.calculateDiscount(orders.size());
}
@Tool(description = "Check if customer is eligible for premium service")
public boolean isPremiumEligible() {
return orders.stream()
.mapToDouble(Order::getTotal)
.sum() > 1000.0;
}
public void updateLoyaltyLevel() { (2)
// Internal business logic
}
}
| 1 | The @Tool annotation exposes this method to LLMs when the object is added via PrompRunner.withToolObject(). |
| 2 | Unannotated methods such as updateLoyaltyLevel are never exposed to LLMs, regardless of their visibility level.
This ensures that tool exposure is safe, explicit and controlled. |
3.4.2. Selective Tool Exposure
The @Tool annotation allows you to selectively expose domain object methods to LLMs.
For example:
-
Business Logic: Expose methods that provide safely invocable business value to the LLM
-
Calculated Properties: Methods that compute derived values. This can help LLMs with calculations they might otherwise get strong.
-
Business Rules: Methods that implement domain-specific rules
| Always keep internal implementation details hidden, and think carefully before exposing methods that mutate state or have side effects. |
3.4.3. Use of Domain Objects in Actions
Domain objects can be used naturally in action methods, combining LLM interactions with traditional object-oriented programming. The availability of the domain object instances also drives Embabel planning.
@Action
public Recommendation generateRecommendation(Customer customer, OperationContext context) {
var prompt = String.format(
"Generate a personalized recommendation for %s based on their profile",
customer.getName()
);
return context.ai()
.withToolObject(customer) (1)
.withDefaultLlm()
.createObject(prompt, Recommendation.class);
}
| 1 | The Customer domain object is provided as a tool object, allowing the LLM to call its @Tool methods.
The LLM has access to customer.getLoyaltyDiscount() and customer.isPremiumEligible(). |
Domain object methods, even if annotated, will not be exposed to LLMs unless explicitly added via withToolObject().
|
3.4.4. Domain Understanding is Critical
As outlined in Rod Johnson’s blog introducing DICE (Domain-Integrated Context Engineering) in Context Engineering Needs Domain Understanding, domain understanding is fundamental to effective context engineering. Domain objects serve as the bridge between:
-
Business Domain: Real-world entities and their relationships
-
Agent Behavior: How LLMs understand and interact with the domain
-
Code Actions: Traditional programming logic that operates on domain objects
3.4.5. Benefits
-
Rich Context: LLMs receive both data structure and behavioral context
-
Encapsulation: Business logic stays within domain objects where it belongs
-
Reusability: Domain objects can be used across multiple agents
-
Testability: Domain logic can be unit tested independently
-
Evolution: Adding new tools to domain objects extends agent capabilities
This approach ensures that agents work with meaningful business entities rather than generic data structures, leading to more natural and effective AI interactions.
3.5. Configuration
3.5.1. Enabling Embabel
Annotate your Spring Boot application class to get agentic behavior.
Example:
@SpringBootApplication
class MyAgentApplication {
public static void main(String[] args) {
SpringApplication.run(MyAgentApplication.class, args);
}
}
This is a normal Spring Boot application class.
You can add other Spring Boot annotations as needed.
Add the following dependency to your pom.xml to include the Ollama model provider starter:
<dependency>
<groupId>com.embabel.agent</groupId>
<artifactId>embabel-agent-starter-ollama</artifactId>
</dependency>
Other providers such as OpenAI, Anthropic, AWS Bedrock, Docker Models, etc. can be added similarly by including their respective starter dependencies.
3.5.2. Configuration Properties
The following table lists all available configuration properties in Embabel Agent Platform.
Properties are organized by their configuration prefix and include default values where applicable.
They can be set via application.properties, application.yml, profile-specific configuration files or environment variables, as per standard Spring configuration practices.
Setting default LLM and roles
From ConfigurableModelProviderProperties - configuration for default LLMs and role-based model selection.
| Property | Type | Default | Description |
|---|---|---|---|
|
String |
|
Default LLM name. It’s good practice to override this in configuration. |
|
String |
|
Default embedding model name. Need not be set, in which case it defaults to null. |
|
Map<String, String> |
|
Map of role to LLM name. Each entry will require an LLM to be registered with the same name. May not include the default LLM. |
|
Map<String, String> |
|
Map of role to embedding service name. Does not need to include the default embedding service. You can create as many roles as you wish. |
Role-based model selection allows you to assign specific LLMs or embedding services to different roles within your application. For example:
embabel:
models:
default-llm: gpt-4o-mini
default-embedding-model: text-embedding-3-small
llms:
cheapest: gpt-4o-mini
best: gpt-4o
reasoning: o1-preview
embedding-services:
fast: text-embedding-3-small
accurate: text-embedding-3-large
It’s good practice to decouple your code from specific models in this way.
Platform Configuration
From AgentPlatformProperties - unified configuration for all agent platform properties.
| Property | Type | Default | Description |
|---|---|---|---|
|
String |
|
Core platform identity name |
|
String |
|
Platform description |
Logging Personality
Configuration for agent logging output style and theming.
| Property | Type | Default | Description |
|---|---|---|---|
|
String |
(none) |
Themed logging messages to add personality to agent output |
| Value | Description |
|---|---|
|
Star Wars themed logging messages |
|
Severance themed logging messages. Praise Kier |
|
Colossus: The Forbin Project themed messages |
|
Hitchhiker’s Guide to the Galaxy themed messages |
|
Monty Python themed logging messages |
embabel:
agent:
logging:
personality: hitchhiker
Agent Scanning
From AgentPlatformProperties.ScanningConfig - configures scanning of the classpath for agents.
| Property | Type | Default | Description |
|---|---|---|---|
|
Boolean |
|
Whether to auto register beans with @Agent and @Agentic annotation |
|
Boolean |
|
Whether to auto register as agents Spring beans of type |
Ranking Configuration
From AgentPlatformProperties.RankingConfig - configures ranking of agents and goals based on user input when the platform should choose the agent or goal.
| Property | Type | Default | Description |
|---|---|---|---|
|
String |
|
Name of the LLM to use for ranking, or null to use auto selection |
|
Int |
|
Maximum number of attempts to retry ranking |
|
Long |
|
Initial backoff time in milliseconds |
|
Double |
|
Multiplier for backoff time |
|
Long |
|
Maximum backoff time in milliseconds |
LLM Operations
From AgentPlatformProperties.LlmOperationsConfig - configuration for LLM operations including prompts and data binding.
| Property | Type | Default | Description |
|---|---|---|---|
|
String |
|
Template for "maybe" prompt, enabling failure result when LLM lacks information |
|
Boolean |
|
Whether to generate examples by default |
|
Int |
|
Maximum retry attempts for data binding |
|
Long |
|
Fixed backoff time in milliseconds between retries |
Process ID Generation
From AgentPlatformProperties.ProcessIdGenerationConfig - configuration for process ID generation.
| Property | Type | Default | Description |
|---|---|---|---|
|
Boolean |
|
Whether to include version in process ID generation |
|
Boolean |
|
Whether to include agent name in process ID generation |
Autonomy Configuration
From AgentPlatformProperties.AutonomyConfig - configures thresholds for agent and goal selection.
Certainty below thresholds will result in failure to choose an agent or goal.
| Property | Type | Default | Description |
|---|---|---|---|
|
Double |
|
Confidence threshold for agent operations |
|
Double |
|
Confidence threshold for goal achievement |
Model Provider Configuration
From AgentPlatformProperties.ModelsConfig - model provider integration configurations.
Anthropic
| Property | Type | Default | Description |
|---|---|---|---|
|
Int |
|
Maximum retry attempts |
|
Long |
|
Initial backoff time in milliseconds |
|
Double |
|
Backoff multiplier |
|
Long |
|
Maximum backoff interval in milliseconds |
OpenAI
| Property | Type | Default | Description |
|---|---|---|---|
|
Int |
|
Maximum retry attempts |
|
Long |
|
Initial backoff time in milliseconds |
|
Double |
|
Backoff multiplier |
|
Long |
|
Maximum backoff interval in milliseconds |
Google GenAI (Native)
Uses the native Google GenAI SDK (spring-ai-google-genai) for direct access to Gemini models with full feature support.
| Property | Type | Default | Description |
|---|---|---|---|
|
Int |
|
Maximum retry attempts |
|
Long |
|
Initial backoff time in milliseconds |
|
Double |
|
Backoff multiplier |
|
Long |
|
Maximum backoff interval in milliseconds |
Google GenAI models are configured via the embabel-agent-starter-google-genai starter dependency.
The following environment variables control authentication:
| Environment Variable | Description |
|---|---|
|
API key for Google AI Studio authentication |
|
Google Cloud project ID (for Vertex AI authentication) |
|
Google Cloud region, e.g., |
Either GOOGLE_API_KEY or both GOOGLE_PROJECT_ID and GOOGLE_LOCATION must be set.
|
Gemini 3 models are only available in the global location on Vertex AI.
To use Gemini 3 with Vertex AI, you must set GOOGLE_LOCATION=global.
|
Server-Sent Events
From AgentPlatformProperties.SseConfig - server-sent events configuration.
| Property | Type | Default | Description |
|---|---|---|---|
|
Int |
|
Maximum buffer size for SSE |
|
Int |
|
Maximum number of process buffers |
Test Configuration
From AgentPlatformProperties.TestConfig - test configuration.
| Property | Type | Default | Description |
|---|---|---|---|
|
Boolean |
|
Whether to enable mock mode for testing |
Process Repository Configuration
From ProcessRepositoryProperties - configuration for the agent process repository.
| Property | Type | Default | Description |
|---|---|---|---|
|
Int |
|
Maximum number of agent processes to keep in memory when using default |
Standalone LLM Configuration
LLM Operations Prompts
From LlmOperationsPromptsProperties - properties for ChatClientLlmOperations operations.
| Property | Type | Default | Description |
|---|---|---|---|
|
String |
|
Template to use for the "maybe" prompt, which can enable a failure result if the LLM does not have enough information to create the desired output structure |
|
Boolean |
|
Whether to generate examples by default |
|
Duration |
|
Default timeout for operations |
LLM Data Binding
From LlmDataBindingProperties - data binding properties with retry configuration for LLM operations.
| Property | Type | Default | Description |
|---|---|---|---|
|
Int |
|
Maximum retry attempts for data binding |
|
Long |
|
Fixed backoff time in milliseconds between retries |
Additional Model Providers
AWS Bedrock
From BedrockProperties - AWS Bedrock model configuration properties.
| Property | Type | Default | Description |
|---|---|---|---|
|
List |
|
List of Bedrock models to configure |
|
String |
|
Model name |
|
String |
|
Knowledge cutoff date |
|
Double |
|
Input token price |
|
Double |
|
Output token price |
Docker Local Models
From DockerProperties - configuration for Docker local models (OpenAI-compatible).
| Property | Type | Default | Description |
|---|---|---|---|
|
String |
Base URL for Docker model endpoint |
|
|
Int |
|
Maximum retry attempts |
|
Long |
|
Initial backoff time in milliseconds |
|
Double |
|
Backoff multiplier |
|
Long |
|
Maximum backoff interval in milliseconds |
Migration Support
From DeprecatedPropertyScanningConfig and DeprecatedPropertyWarningConfig - configuration for migrating from older versions of Embabel.
| These properties will be removed before Embabel 1.0.0 release. |
| Property | Type | Default | Description |
|---|---|---|---|
|
Boolean |
|
Whether deprecated property scanning is enabled (disabled by default for production safety) |
|
List<String> |
|
Base packages to scan for deprecated conditional annotations |
|
List<String> |
Extensive default list |
Package prefixes to exclude from scanning |
|
List<String> |
|
Additional user-specific packages to exclude |
|
Boolean |
|
Whether to automatically exclude JAR-based packages using classpath detection |
|
Int |
|
Maximum depth for package scanning |
|
Boolean |
|
Whether to enable individual warning logging. When false, only aggregated summary is logged |
3.6. Annotation model
Embabel provides a Spring-style annotation model to define agents, actions, goals, and conditions. This is the recommended model to use in Java, and remains compelling in Kotlin.
3.6.1. The @Agent annotation
This annotation is used on a class to define an agent. It is a Spring stereotype annotation, so it triggers Spring component scanning. Your agent class will automatically be registered as a Spring bean. It will also be registered with the agent framework, so it can be used in agent processes.
You must provide the description parameter, which is a human-readable description of the agent.
This is particularly important as it may be used by the LLM in agent selection.
3.6.2. The @Action annotation
The @Action annotation is used to mark methods that perform actions within an agent.
Action metadata can be specified on the annotation, including:
-
description: A human-readable description of the action. -
pre: A list of preconditions additional to the input types that must be satisfied before the action can be executed. -
post: A list of postconditions additional to the output type(s) that may be satisfied after the action is executed. -
canRerun: A boolean indicating whether the action can be rerun if it has already been executed. Defaults to false. -
clearBlackboard: A boolean indicating whether to clear the blackboard after this action completes. When true, all objects on the blackboard are removed except the action’s output. This is useful for resetting context in multi-step workflows. It can also make persistence of flows more efficient by dispensing with objects that are no longer needed. Defaults to false. -
cost:Relative cost of the action from 0-1. Defaults to 0.0. -
value: Relative value of performing the action from 0-1. Defaults to 0.0. -
toolGroups: Named tool groups the action requires. -
toolGroupRequirements: Tool group requirements with QoS constraints.
Clearing the Blackboard
The clearBlackboard attribute is useful in multi-step workflows where you want to reset the processing context.
When an action with clearBlackboard = true completes, all objects on the blackboard are removed except the action’s output.
This prevents accumulated intermediate data from affecting subsequent processing.
@Agent(description = "Multi-step document processing")
public class DocumentProcessor {
@Action(clearBlackboard = true) (1)
public ProcessedDocument preprocess(RawDocument doc) {
return new ProcessedDocument(doc.getContent().trim());
}
@AchievesGoal(description = "Produce final output")
@Action
public FinalOutput transform(ProcessedDocument doc) { (2)
return new FinalOutput(doc.getContent().toUpperCase());
}
}
| 1 | After preprocess completes, the blackboard is cleared and only ProcessedDocument remains.
The original RawDocument is removed. |
| 2 | The transform action receives only the ProcessedDocument, not any earlier inputs. |
Avoid using clearBlackboard on goal-achieving actions (those with @AchievesGoal).
Clearing the blackboard removes hasRun tracking conditions, which may interfere with goal satisfaction.
Use clearBlackboard on intermediate actions instead.
|
Dynamic Cost Computation with @Cost
While the cost and value fields on @Action allow specifying static values, you can compute these dynamically at planning time using the @Cost annotation.
This is useful when the cost of an action depends on the current state of the blackboard.
The @Cost annotation marks a method that returns a cost value (a double between 0.0 and 1.0).
You then reference this method from the @Action annotation using costMethod or valueMethod.
@Agent(description = "Processor with dynamic cost")
public class DataProcessor {
@Cost(name = "processingCost") (1)
public double computeProcessingCost(@Nullable LargeDataSet data) { (2)
if (data != null && data.size() > 1000) {
return 0.9; // High cost for large datasets
}
return 0.1; // Low cost for small or missing datasets
}
@Action(costMethod = "processingCost") (3)
public ProcessedData process(RawData input) {
return new ProcessedData(input.transform());
}
}
| 1 | The @Cost annotation marks a method for dynamic cost computation.
The name parameter identifies this cost method. |
| 2 | Domain object parameters in @Cost methods must be nullable.
If the object isn’t on the blackboard, null is passed. |
| 3 | The costMethod field references the @Cost method by name. |
Key differences from @Condition methods:
-
All domain object parameters in
@Costmethods must be nullable (use@Nullablein Java or?in Kotlin) -
When a domain object is not available on the blackboard,
nullis passed instead of causing the method to fail -
The method must return a
doublebetween 0.0 and 1.0 -
The
Blackboardcan be passed as a parameter for direct access to all available objects
You can also compute dynamic value using valueMethod:
@Agent(description = "Agent with dynamic value computation")
class PrioritizedAgent {
@Cost(name = "urgencyValue")
fun computeUrgency(task: Task?): Double {
return when {
task == null -> 0.5
task.priority == Priority.HIGH -> 1.0
task.priority == Priority.MEDIUM -> 0.6
else -> 0.2
}
}
@AchievesGoal(description = "Process high-priority tasks")
@Action(valueMethod = "urgencyValue")
fun processTask(task: Task): Result {
return Result("Processed: ${task.name}")
}
}
The @Cost method is called during planning, before the action executes.
It allows the planner to make informed decisions about which actions to prefer based on runtime state.
|
Dynamic cost is especially useful with Utility planning (PlannerType.UTILITY), where cost/value tradeoffs are a core concept.
The utility planner evaluates actions based on their net value (value minus cost), making dynamic cost computation essential for sophisticated decision-making.
|
3.6.3. The @Condition annotation
The @Condition annotation is used to mark methods that evaluate conditions.
They can take an OperationContext parameter to access the blackboard and other infrastructure.
If they take domain object parameters, the condition will automatically be false until suitable instances are available.
Condition methods should not have side effects—for example, on the blackboard. This is important because they may be called multiple times.
Both Action and Condition methods may be inherited from superclasses. That is, annotated methods on superclasses will be treated as actions on a subclass instance.
Give your Action and Condition methods unique names, so the planner can distinguish between them.
3.6.4. Parameters
@Action methods must have at least one parameter.
@Condition methods must have zero or more parameters, but otherwise follow the same rules as @Action methods regarding parameters.
Ordering of parameters is not important.
Parameters fall in two categories:
-
Domain objects. These are the normal inputs for action methods. They are backed by the blackboard and will be used as inputs to the action method. A nullable domain object parameter will be populated if it is non-null on the blackboard. This enables nice-to-have parameters that are not required for the action to run. In Kotlin, use a nullable parameter with
?: in Java, mark the parameter with theorg.springframework.lang.Nullableor anotherNullableannotation. -
Infrastructure parameters, such as the
OperationContext,ProcessContext, andAimay be used in action or condition methods.
| Domain objects drive planning, specifying the preconditions to an action. |
The ActionContext or ExecutingOperationContext subtype can be used in action methods.
It adds asSubProcess methods that can be used to run other agents in subprocesses.
This is an important element of composition.
Use the least specific type possible for parameters. Use
OperationContextunless you are creating a subprocess.
Custom Parameters
Besides two default parameter categories described above, you can provide your own parameters by implementing the ActionMethodArgumentResolver interface.
The two main methods of this interface are:
-
supportsParameter, which indicates what kind of parameters are supported, and -
resolveArgument, which resolves the argument into an object used to invoke the action method.
Note the similarity with Spring MVC, where you can provide custom parameters by implementing a HandlerMethodArgumentResolver.
|
All default parameters are provided by
ActionMethodArgumentResolverimplementations.
To register your custom argument resolver, provide it to the DefaultActionMethodManager component in your Spring configuration.
Typically, you will register (some of) the defaults as well your custom resolver, in order to support the default parameters.
Make sure to register the BlackboardArgumentResolver as last resolver, to ensure that others take precedence.
|
3.6.5. Binding by name
The @RequireNameMatch annotation can be used to bind parameters by name.
3.6.6. Reactive triggers with trigger
The trigger field on the @Action annotation enables reactive behavior where an action only fires when a specific type is the most recently added value to the blackboard.
This is useful in event-driven scenarios where you want to react to a particular event even when multiple parameters of various types are available.
For example, in a chat system you might want an action to fire only when a new user message arrives, not when other context is updated:
@Agent(description = "Chat message handler")
public class ChatAgent {
@AchievesGoal(description = "Respond to user message")
@Action(trigger = UserMessage.class) (1)
public Response handleMessage(
UserMessage message,
Conversation conversation (2)
) {
return new Response("Received: " + message.content());
}
}
| 1 | The trigger field means this action only fires when UserMessage is the last result added to the blackboard. |
| 2 | Conversation must also be available, but doesn’t need to be the triggering event. |
Without trigger, an action fires as soon as all its parameters are available on the blackboard.
With trigger, the specified type must additionally be the most recent value added.
This is particularly useful when:
-
You have multiple actions that could handle different event types
-
You want to distinguish between "data available" and "event just occurred"
-
You’re building event-driven or reactive workflows
@Agent(description = "Multi-event processor")
public class EventProcessor {
@Action(trigger = EventA.class) (1)
public Result handleEventA(EventA eventA, EventB eventB) {
return new Result("Triggered by A");
}
@AchievesGoal(description = "Handle event B")
@Action(trigger = EventB.class) (2)
public Result handleEventB(EventA eventA, EventB eventB) {
return new Result("Triggered by B");
}
}
| 1 | handleEventA fires when EventA is added (and EventB is available). |
| 2 | handleEventB fires when EventB is added (and EventA is available). |
The trigger field checks that the specified type matches the lastResult() on the blackboard.
The last result is the most recent object added via any binding operation.
|
3.6.7. Handling of return types
Action methods normally return a single domain object.
Nullable return types are allowed. Returning null will trigger replanning. There may or not be an alternative path from that point, but it won’t be what the planner was previously trying to achieve.
There is a special case where the return type can essentially be a union type, where the action method can return one ore more of several types.
This is achieved by a return type implementing the SomeOf tag interface.
Implementations of this interface can have multiple nullable fields.
Any non-null values will be bound to the blackboard, and the postconditions of the action will include all possible fields of the return type.
For example:
// Must implement the SomeOf interface
data class FrogOrDog(
val frog: Frog? = null,
val dog: Dog? = null,
) : SomeOf
@Agent(description = "Illustrates use of the SomeOf interface")
class ReturnsFrogOrDog {
@Action
fun frogOrDog(): FrogOrDog {
return FrogOrDog(frog = Frog("Kermit"))
}
// This works because the frog field of the return type was set
@AchievesGoal(description = "Create a prince from a frog")
@Action
fun toPerson(frog: Frog): PersonWithReverseTool {
return PersonWithReverseTool(frog.name)
}
}
This enables routing scenarios in an elegant manner.
Multiple fields of the SomeOf instance may be non-null and this is not an error.
It may enable the most appropriate routing.
|
Routing can also be achieved via subtypes, as in the following example:
@Action
fun classifyIntent(userInput: UserInput): Intent? = (1)
when (userInput.content) {
"billing" -> BillingIntent()
"sales" -> SalesIntent()
"service" -> ServiceIntent()
else -> {
loggerFor<IntentReceptionAgent>().warn("Unknown intent: $userInput")
null
}
}
@Action
fun billingAction(intent: BillingIntent): IntentClassificationSuccess { (2)
return IntentClassificationSuccess("billing")
}
@Action
fun salesAction(intent: SalesIntent): IntentClassificationSuccess {
return IntentClassificationSuccess("sales")
}
// ...
| 1 | Classification action returns supertype Intent.
Real classification would likely use an LLM. |
| 2 | billingAction and other action methods takes a subtype of Intent, so will only be invoked if the classification action returned that subtype. |
3.6.8. Action method implementation
Embabel makes it easy to seamlessly integrate LLM invocation and application code, using common types.
An @Action method is a normal method, and can use any libraries or frameworks you like.
The only special thing about it is its ability to use the OperationContext parameter to access the blackboard and invoke LLMs.
3.6.9. The @AchievesGoal annotation
The @AchievesGoal annotation can be added to an @Action method to indicate that the completion of the action achieves a specific goal.
3.6.10. Implementing the StuckHandler interface
If an annotated agent class implements the StuckHandler interface, it can handle situations where an action is stuck itself.
For example, it can add data to the blackboard.
Example:
@Agent(
description = "self unsticking agent",
)
class SelfUnstickingAgent : StuckHandler {
// The agent will get stuck as there's no dog to convert to a frog
@Action
@AchievesGoal(description = "the big goal in the sky")
fun toFrog(dog: Dog): Frog {
return Frog(dog.name)
}
// This method will be called when the agent is stuck
override fun handleStuck(agentProcess: AgentProcess): StuckHandlerResult {
called = true
agentProcess.addObject(Dog("Duke"))
return StuckHandlerResult(
message = "Unsticking myself",
handler = this,
code = StuckHandlingResultCode.REPLAN,
agentProcess = agentProcess,
)
}
}
3.6.11. Advanced Usage: Nested processes
An @Action method can invoke another agent process.
This is often done to use a stereotyped process that is composed using the DSL.
Use the ActionContext.asSubProcess method to create a sub-process from the action context.
For example:
@Action
fun report(
reportRequest: ReportRequest,
context: ActionContext,
): ScoredResult<Report, SimpleFeedback> = context.asSubProcess(
// Will create an agent sub process with strong typing
EvaluatorOptimizer.generateUntilAcceptable(
maxIterations = 5,
generator = {
it.promptRunner().withToolGroup(CoreToolGroups.WEB).create(
"""
Given the topic, generate a detailed report in ${reportRequest.words} words.
# Topic
${reportRequest.topic}
# Feedback
${it.input ?: "No feedback provided"}
""".trimIndent()
)
},
evaluator = {
it.promptRunner().withToolGroup(CoreToolGroups.WEB).create(
"""
Given the topic and word count, evaluate the report and provide feedback
Feedback must be a score between 0 and 1, where 1 is perfect.
# Report
${it.input.report}
# Report request:
${reportRequest.topic}
Word count: ${reportRequest.words}
""".trimIndent()
)
},
))
3.6.12. Running Subagents with RunSubagent
The RunSubagent utility provides a convenient way to run a nested agent from within an @Action method without needing direct access to ActionContext.
This is particularly useful when you want to delegate work to another @Agent-annotated class or an Agent instance.
Running an @Agent-annotated Instance
Use RunSubagent.fromAnnotatedInstance() when you have an instance of a class annotated with @Agent:
The annotated instance can be Spring-injected into your agent class.
Since @Agent is a Spring stereotype annotation, you can inject one agent into another and run it as a subagent.
This enables clean separation of concerns while maintaining testability.
|
@Agent(description = "Outer agent that delegates to an injected subagent")
public class OuterAgent {
private final InnerSubAgent innerSubAgent;
public OuterAgent(InnerSubAgent innerSubAgent) { (1)
this.innerSubAgent = innerSubAgent;
}
@Action
public TaskOutput start(UserInput input) {
return RunSubagent.fromAnnotatedInstance(
innerSubAgent, (2)
TaskOutput.class
);
}
@Action
@AchievesGoal(description = "Processing complete")
public TaskOutput done(TaskOutput output) {
return output;
}
}
@Agent(description = "Inner subagent that processes input")
public class InnerSubAgent {
@Action
public Intermediate stepOne(UserInput input) {
return new Intermediate(input.getContent());
}
@Action
@AchievesGoal(description = "Subagent complete")
public TaskOutput stepTwo(Intermediate data) {
return new TaskOutput(data.value().toUpperCase());
}
}
| 1 | Spring injects the InnerSubAgent bean via constructor injection. |
| 2 | The injected instance is passed to RunSubagent.fromAnnotatedInstance(). |
In Kotlin, you can use the reified version for a more concise syntax:
@Agent(description = "Outer agent via reified subagent")
class OuterAgentReified {
@Action
fun start(input: UserInput): TaskOutput =
RunSubagent.fromAnnotatedInstance<TaskOutput>(InnerSubAgent())
@Action
@AchievesGoal(description = "Processing complete")
fun done(output: TaskOutput): TaskOutput = output
}
Running an Agent Instance
Use RunSubagent.instance() when you already have an Agent object (for example, one created programmatically or via AgentMetadataReader):
@Agent(description = "Outer agent with Agent instance")
public class OuterAgentWithAgentInstance {
@Action
public TaskOutput start(UserInput input) {
Agent agent = (Agent) new AgentMetadataReader()
.createAgentMetadata(new InnerSubAgent());
return RunSubagent.instance(agent, TaskOutput.class);
}
@Action
@AchievesGoal(description = "Processing complete")
public TaskOutput done(TaskOutput output) {
return output;
}
}
In Kotlin with reified types:
@Agent(description = "Outer agent via reified agent instance")
class OuterAgentReifiedInstance {
@Action
fun start(input: UserInput): TaskOutput {
val agent = AgentMetadataReader().createAgentMetadata(InnerSubAgent()) as Agent
return RunSubagent.instance<TaskOutput>(agent)
}
@Action
@AchievesGoal(description = "Processing complete")
fun done(output: TaskOutput): TaskOutput = output
}
How It Works
RunSubagent methods throw a SubagentExecutionRequest exception that is caught by the framework.
The framework then executes the subagent as a subprocess within the current agent process, sharing the same blackboard context.
The result of the subagent’s goal-achieving action is returned to the calling action.
This approach has several advantages:
-
Cleaner syntax: No need to pass
ActionContextto the action method -
Type safety: The return type is enforced at compile time
-
Composition: Easily compose complex workflows from simpler agents
-
Reusability: The same subagent can be used in multiple contexts
Comparison with ActionContext.asSubProcess
Both RunSubagent and ActionContext.asSubProcess achieve the same result, but differ in style:
| Approach | When to use | Example |
|---|---|---|
|
When you have an |
|
|
When you have an |
|
|
When you need access to |
|
Use RunSubagent when your action method only needs to delegate to a subagent.
Use ActionContext.asSubProcess() when you need additional context operations.
|
3.7. DSL
You can also create agents using a DSL in Kotlin or Java.
This is useful for workflows where you want an atomic action that is complete in itself but may contain multiple steps or actions.
3.7.1. Standard Workflows
There are a number of standard workflows, constructed by builders, that meet common requirements.
These can be used to create agents that will be exposed as Spring beans, or within @Action methods within other agents.
All are type safe.
As far as possible, they use consistent APIs.
-
SimpleAgentBuilder: The simplest agent, with no preconditions or postconditions. -
ScatterGatherBuilder: Fork join pattern for parallel processing. -
ConsensusBuilder: A pattern for reaching consensus among multiple sources. Specialization ofScatterGather. -
RepeatUntil: Repeats a step until a condition is met. -
RepeatUntilAcceptable: Repeats a step while a condition is met, with a separate evaluator providing feedback.
Creating a simple agent:
var agent = SimpleAgentBuilder
.returning(Joke.class) (1)
.running(tac -> tac.ai() (2)
.withDefaultLlm()
.createObject("Tell me a joke", Joke.class))
.buildAgent("joker", "This is guaranteed to return a dreadful joke"); (3)
| 1 | Specify the return type. |
| 2 | specify the action to run.
Takes a SupplierActionContext<RESULT> OperationContext parameter allowing access to the current AgentProcess. |
| 3 | Build an agent with the given name and description. |
A more complex example:
@Action
FactChecks runAndConsolidateFactChecks(
DistinctFactualAssertions distinctFactualAssertions,
ActionContext context) {
var llmFactChecks = properties.models().stream()
.flatMap(model -> factCheckWithSingleLlm(model, distinctFactualAssertions, context))
.toList();
return ScatterGatherBuilder (1)
.returning(FactChecks.class) (2)
.fromElements(FactCheck.class) (3)
.generatedBy(llmFactChecks) (4)
.consolidatedBy(this::reconcileFactChecks) (5)
.asSubProcess(context); (6)
}
| 1 | Start building a scatter gather agent. |
| 2 | Specify the return type of the overall agent. |
| 3 | Specify the type of elements to be gathered. |
| 4 | Specify the list of functions to run in parallel, each generating an element, here of type FactCheck. |
| 5 | Specify a function to consolidate the results.
In this case it will take a list of FactCheck and return a FactCheck and return a FactChecks object. |
| 6 | Build and run the agent as a subprocess of the current process.
This is an alternative to asAgent shown in the SimpleAgentBuilder example.
The API is consistent. |
| If you wish to experiment, the embabel-agent-examples repository includes the fact checker shown above. |
3.7.2. Registering Agent beans
Whereas the @Agent annotation causes a class to be picked up immediately by Spring, with the DSL you’ll need an extra step to register an agent with Spring. As shown in the example below, any @Bean of Agent type results auto registration, just like declaring a class annotated with @Agent does.
@Configuration
class FactCheckerAgentConfiguration {
@Bean
fun factChecker(factCheckerProperties: FactCheckerProperties): Agent {
return factCheckerAgent(
llms = listOf(
LlmOptions(AnthropicModels.CLAUDE_35_HAIKU).withTemperature(.3),
LlmOptions(AnthropicModels.CLAUDE_35_HAIKU).withTemperature(.0),
),
properties = factCheckerProperties,
)
}
}
3.8. Core Types
3.8.1. LlmOptions
The LlmOptions class specifies which LLM to use and its hyperparameters.
It’s defined in the embabel-common project and provides a fluent API for LLM configuration:
// Create LlmOptions with model and temperature
var options = LlmOptions
.withModel(OpenAiModels.GPT_4O_MINI)
.withTemperature(0.8);
// Use different hyperparameters for different tasks
var analyticalOptions = LlmOptions
.withModel(OpenAiModels.GPT_4O_MINI)
.withTemperature(0.2)
.withTopP(0.9);
Important Methods:
-
withModel(String): Specify the model name -
withRole(String): Specify the model role. The role must be one defined in configuration viaembabel.models.llms.<role>=<model-name> -
withTemperature(Double): Set creativity/randomness (0.0-1.0) -
withTopP(Double): Set nucleus sampling parameter -
withTopK(Integer): Set top-K sampling parameter -
withPersona(String): Add a system message persona
LlmOptions is serializable to JSON, so you can set properties of type
LlmOptions in application.yml and other application configuration files.
This is a powerful way of externalizing not only models, but hyperparameters.
3.8.2. PromptRunner
All LLM calls in user applications should be made via the PromptRunner interface.
Once created, a PromptRunner can run multiple prompts with the same LLM, hyperparameters, tool groups and PromptContributors.
Getting a PromptRunner
You obtain a PromptRunner from an OperationContext using the fluent API:
@Action
public Story createStory(UserInput input, OperationContext context) {
// Get PromptRunner with default LLM
var runner = context.ai().withDefaultLlm();
// Get PromptRunner with specific LLM options
var customRunner = context.ai().withLlm(
LlmOptions.withModel(OpenAiModels.GPT_4O_MINI)
.withTemperature(0.8)
);
return customRunner.createObject("Write a story about: " + input.getContent(), Story.class);
}
PromptRunner Methods
Core Object Creation:
-
createObject(String, Class<T>): Create a typed object from a prompt, otherwise throw an exception. An exception triggers retry. If retry fails repeatedly, re-planning occurs. -
createObjectIfPossible(String, Class<T>): Try to create an object, return null on failure. This can cause replanning. -
generateText(String): Generate simple text response
Normally you want to use one of the createObject methods to ensure the response is typed correctly.
|
Tool and Context Management:
-
withToolGroup(String): Add tool groups for LLM access -
withToolObject(Object): Add domain objects with @Tool methods -
withPromptContributor(PromptContributor): Add context contributors -
withImage(AgentImage): Add an image to the prompt for vision-capable LLMs -
withImages(AgentImage…): Add multiple images to the prompt
LLM Configuration:
-
withLlm(LlmOptions): Use specific LLM configuration -
withGenerateExamples(Boolean): Control example generation
Returning a Specific Type
-
creating(Class<T>): Go into theObjectCreatorfluent API for returning a particular type.
For example:
var story = context.ai()
.withDefaultLlm()
.withToolGroup(CoreToolGroups.WEB)
.creating(Story.class)
.fromPrompt("Create a story about: " + input.getContent());
The main reason to do this is to add strongly typed examples for few-shot prompting. For example:
var story = context.ai()
.withDefaultLlm()
.withToolGroup(CoreToolGroups.WEB)
.withExample("A children's story", new Story("Once upon a time...")) (1)
.creating(Story.class)
.fromPrompt("Create a story about: " + input.getContent());
| 1 | Example: The example will be included in the prompt in JSON format to guide the LLM. |
Working with Images:
var image = AgentImage.fromFile(imageFile);
var answer = context.ai()
.withLlm(AnthropicModels.CLAUDE_35_HAIKU) (1)
.withImage(image) (2)
.generateText("What is in this image?");
| 1 | Vision-capable model required: Use Claude 3.x, GPT-4 Vision, or other multimodal LLMs |
| 2 | Add image: Images are sent with the text prompt to the LLM. Can be used multiple times for multiple images. |
Advanced Features:
-
withTemplate(String): Use Jinja templates for prompts -
withSubagents(Subagent…): Enable handoffs to other agents -
evaluateCondition(String, String): Evaluate boolean condition
Validation
Embabel supports JSR-380 bean validation annotations on domain objects.
When creating objects via PromptRunner.createObject or createObjectIfPossible, validation is automatically performed after deserialization.
If validation fails, Embabel transparently retries the LLM call to obtain a valid object,
describing the validation errors to the LLM to help it correct its response.
If validation fails a second time, InvalidLlmReturnTypeException is thrown.
This will trigger replanning if not caught.
You can also choose to catch it within the action method making the LLM call,
and take appropriate action in your own code.
Simple example of annotation use:
public class User {
@NotNull(message = "Name cannot be null")
private String name;
@AssertTrue(message = "Working must be true")
private boolean working;
@Size(min = 10, max = 200, message
= "About Me must be between 10 and 200 characters")
private String aboutMe;
@Min(value = 18, message = "Age should not be less than 18")
@Max(value = 150, message = "Age should not be greater than 150")
private int age;
@Email(message = "Email should be valid")
private String email;
// standard setters and getters
}
You can also use custom annotations with validators that will be injected by Spring. For example:
@Target({ElementType.FIELD, ElementType.PARAMETER}) (1)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PalindromeValidator.class)
public @interface MustBePalindrome {
String message() default "Must be a palindrome";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class Palindromic {
@MustBePalindrome (2)
private String eats;
public Palindromic(String eats) {
this.eats = eats;
}
public String getEats() {
return eats;
}
}
@Component (3)
public class PalindromeValidator implements ConstraintValidator<MustBePalindrome, String> {
private final Ai ai; (4)
public PalindromeValidator(Ai ai) {
this.ai = ai;
}
@Override
public boolean isValid(String field, ConstraintValidatorContext context) {
if (field == null) {
return false;
}
return field.equals(new StringBuilder(field).reverse().toString());
}
}
| 1 | Define the custom annotation |
| 2 | Apply the annotation to a field |
| 3 | Implement the validator as a Spring component. Note the @Component annotation. |
| 4 | Spring will inject the validator with dependencies, such as the Ai instance in this case |
Thus we have standard JSR-280 validation with full Spring dependency injection support.
3.8.3. AgentImage
Represents an image for use with vision-capable LLMs.
Factory Methods:
-
AgentImage.fromFile(File): Load from file (auto-detects MIME type from common extensions) -
AgentImage.fromPath(Path): Load from path (auto-detects MIME type) -
AgentImage.create(String, byte[]): Create with explicit MIME type and byte array -
AgentImage.fromBytes(String, byte[]): Create from filename and bytes (auto-detects MIME type)
For uncommon image formats or if auto-detection fails, use AgentImage.create() with an explicit MIME type.
3.9. Tools
Tools can be passed to LLMs to allow them to perform actions.
Tools can either be outside the JVM process, as with MCP, or inside the JVM process, as with domain objects exposing @Tool methods.
Embabel allows you to provide tools to LLMs in two ways:
-
Via the
PromptRunnerby providing one or more in process tool instances. A tool instance is an object annotated with@Toolmethods. -
At action or
PromptRunnerlevel, from a tool group.
LlmReference implementations also expose tools, but this is handled internally by the framework.
3.9.1. In Process Tools: Implementing Tool Instances
Implement one or more methods annotated with @Tool on a class.
You do not need to annotate the class itself.
Each annotated method represents a distinct tool that will be exposed to the LLM.
A simple example of a tool method:
class MathTools {
@Tool(description = "add two numbers")
fun add(a: Double, b: Double) = a + b
// Other tools
Classes implementing tools can be stateful. They are often domain objects. Tools on mapped entities are especially useful, as they can encapsulate state that is never exposed to the LLM. See Domain Tools: Direct Access, Zero Ceremony for a discussion of tool use patterns.
The @Tool annotation comes from Spring AI.
Tool methods can have any visibility, and can be static or instance scope. They are allowed on inner classes.
You can define any number of arguments for the method (including no argument) with most types (primitives, POJOs, enums, lists, arrays, maps, and so on). Similarly, the method can return most types, including void. If the method returns a value, the return type must be a serializable type, as the result will be serialized and sent back to the model.
The following types are not currently supported as parameters or return types for methods used as tools:
Optional
Asynchronous types (e.g. CompletableFuture, Future)
Reactive types (e.g. Flow, Mono, Flux)
Functional types (e.g. Function, Supplier, Consumer).
Tool Calling
You can obtain the current AgentProcess in a Tool method implementation via AgentProcess.get().
This enables tools to bind to the AgentProcess, making objects available to other actions.
For example:
@Tool(description="My Tool") String bindCustomer(Long id) {
var customer = customerRepository.findById(id); var agentProcess = AgentProcess.get(); if (agentProcess != null) {
agentProcess.addObject(customer); return "Customer bound to blackboard"; } return "No agent process: Unable to bind customer"; }
3.9.2. Tool Groups
Embabel introduces the concept of a tool group. This is a level of indirection between user intent and tool selection. For example, we don’t ask for Brave or Google web search: we ask for "web" tools, which may be resolved differently in different environments.
| Tools use should be focused. Thus tool groups are not specified at agent level, but on individual actions. |
Tool groups are often backed by MCP.
Configuring Tool Groups in configuration files
If you have configured MCP servers in your application configuration, you can selectively expose tools from those servers to agents by configuring tool groups.
The easiest way to do this is in your application.yml or application.properties file.
Select tools by name.
For example:
embabel:
agent:
platform:
tools:
includes:
weather:
description: Get weather for location
provider: Docker
tools:
- weather
Configuring Tool Groups in Spring @Configuration
You can also use Spring’s @Configuration and @Bean annotations to expose ToolGroups to the agent platform with greater control.
The framework provides a default ToolGroupsConfiguration that demonstrates how to inject MCP servers and selectively expose MCP tools:
@Configuration class ToolGroupsConfiguration(
private val mcpSyncClients: List<McpSyncClient>) {
@Bean
fun mathToolGroup() = MathTools()
@Bean
fun mcpWebToolsGroup(): ToolGroup { (1)
return McpToolGroup(
description = CoreToolGroups.WEB_DESCRIPTION,
name = "docker-web",
provider = "Docker",
permissions = setOf(ToolGroupPermission.INTERNET_ACCESS),
clients = mcpSyncClients,
filter = {
// Only expose specific web tools, exclude rate-limited ones
(it.toolDefinition.name().contains("brave") ||
it.toolDefinition.name().contains("fetch")) &&
!it.toolDefinition.name().contains("brave_local_search")
}
)
}
}
| 1 | This method creates a Spring bean of type ToolGroup.
This will automatically be picked up by the agent platform, allowing the tool group to be requested by name (role). |
Key Configuration Patterns
MCP Client Injection:
The configuration class receives a List<McpSyncClient> via constructor injection.
Spring automatically provides all available MCP clients that have been configured in the application.
Selective Tool Exposure:
Each McpToolGroup uses a filter lambda to control which tools from the MCP servers are exposed to agents.
This allows fine-grained control over tool availability and prevents unwanted or problematic tools from being used.
Tool Group Metadata:
Tool groups include descriptive metadata like name, provider, and description to help agents understand their capabilities.
The permissions property declares what access the tool group requires (e.g., INTERNET_ACCESS).
Creating Custom Tool Group Configurations
Applications can implement their own @Configuration classes to expose custom tool groups, which can be backed by any service or resource, not just MCP.
@Configuration
public class MyToolGroupsConfiguration {
@Bean
public ToolGroup databaseToolsGroup(DataSource dataSource) {
return new DatabaseToolGroup(dataSource);
}
@Bean
public ToolGroup emailToolsGroup(EmailService emailService) {
return new EmailToolGroup(emailService);
}
}
This approach leverages Spring’s dependency injection to provide tool groups with the services and resources they need, while maintaining clean separation of concerns between tool configuration and agent logic.
Tool Usage in Action Methods
The toolGroups parameter on @Action methods specifies which tool groups are required for that action to execute.
The framework automatically provides these tools to the LLM when the action runs.
Here’s an example from the StarNewsFinder agent that demonstrates web tool usage:
- Java
-
// toolGroups specifies tools that are required for this action to run @Action(toolGroups = {CoreToolGroups.WEB}) public RelevantNewsStories findNewsStories( StarPerson person, Horoscope horoscope, OperationContext context) { var prompt = """ %s is an astrology believer with the sign %s. Their horoscope for today is: <horoscope>%s</horoscope> Given this, use web tools and generate search queries to find %d relevant news stories summarize them in a few sentences. Include the URL for each story. Do not look for another horoscope reading or return results directly about astrology; find stories relevant to the reading above. """.formatted( person.name(), person.sign(), horoscope.summary(), storyCount); return context.ai().withDefaultLlm().createObject(prompt, RelevantNewsStories.class); } - Kotlin
-
// toolGroups specifies tools that are required for this action to run @Action(toolGroups = [CoreToolGroups.WEB, CoreToolGroups.BROWSER_AUTOMATION]) internal fun findNewsStories( person: StarPerson, horoscope: Horoscope, context: OperationContext, ): RelevantNewsStories = context.ai().withDefaultLlm() createObject ( """ ${person.name} is an astrology believer with the sign ${person.sign}. Their horoscope for today is: <horoscope>${horoscope.summary}</horoscope> Given this, use web tools and generate search queries to find $storyCount relevant news stories summarize them in a few sentences. Include the URL for each story. Do not look for another horoscope reading or return results directly about astrology; find stories relevant to the reading above. """.trimIndent() )
Key Tool Usage Patterns
Tool Group Declaration:
The toolGroups parameter on @Action methods explicitly declares which tool groups the action needs.
This ensures the LLM has access to the appropriate tools when executing that specific action.
Multiple Tool Groups:
Actions can specify multiple tool groups (e.g., [CoreToolGroups.WEB, CoreToolGroups.BROWSER_AUTOMATION]) when they need different types of capabilities.
Automatic Tool Provisioning: The framework automatically makes the specified tools available to the LLM during the action execution. Developers don’t need to manually manage tool availability - they simply declare what’s needed.
Tool-Aware Prompts: Prompts should explicitly instruct the LLM to use the available tools. For example, "use web tools and generate search queries" clearly directs the LLM to utilize the web search capabilities.
Using Tools at PromptRunner Level
Instead of declaring tools at the action level, you can also specify tools directly on the PromptRunner for more granular control:
// Add tool groups to a specific prompt
context.ai().withAutoLlm().withToolGroup(CoreToolGroups.WEB).create(
"""
Given the topic, generate a detailed report using web research.
# Topic
${reportRequest.topic}
""".trimIndent()
)
// Add multiple tool groups
context.ai().withDefaultLlm()
.withToolGroup(CoreToolGroups.WEB)
.withToolGroup(CoreToolGroups.MATH)
.createObject("Calculate stock performance with web data", StockReport::class)
Adding Tool Objects with @Tool Methods:
You can also provide domain objects with @Tool methods directly to specific prompts:
context.ai()
.withDefaultLlm()
.withToolObject(jokerTool)
.createObject("Create a UserInput object for fun", UserInput.class);
// Add tool object with filtering and custom naming strategy
context.ai()
.withDefaultLlm()
.withToolObject(
ToolObject(calculatorService)
.withNamingStrategy { "calc_$it" }
.withFilter { methodName -> methodName.startsWith("compute") }
).createObject("Perform calculations", Result.class);
Available PromptRunner Tool Methods:
-
withToolGroup(String): Add a single tool group by name -
withToolGroup(ToolGroup): Add a specific ToolGroup instance -
withToolGroups(Set<String>): Add multiple tool groups -
withTools(vararg String): Convenient method to add multiple tool groups -
withToolObject(Any): Add domain object with @Tool methods -
withToolObject(ToolObject): Add ToolObject with custom configuration -
withTool(Tool): Add a framework-agnostic Tool instance -
withTools(List<Tool>): Add multiple framework-agnostic Tool instances
3.9.3. Framework-Agnostic Tool Interface
In addition to Spring AI’s @Tool annotation, Embabel provides its own framework-agnostic Tool interface in the com.embabel.agent.api.tool package.
This allows you to create tools that are not tied to any specific LLM framework, making your code more portable and testable.
The Tool interface includes nested types to avoid naming conflicts with framework-specific types:
-
Tool.Definition- Describes the tool (name, description, input schema) -
Tool.InputSchema- Defines the parameters the tool accepts -
Tool.Parameter- A single parameter with name, type, and description -
Tool.Result- The result returned by a tool (text, artifact, or error) -
Tool.Handler- Functional interface for implementing tool logic
Creating Tools Programmatically
You can create tools using the Tool.create() factory methods:
- Java
-
// Simple tool with no parameters Tool greetTool = Tool.create( "greet", "Greets the user", (input) -> Tool.Result.text("Hello!") ); // Tool with parameters Tool addTool = Tool.create( "add", "Adds two numbers together", Tool.InputSchema.of( new Tool.Parameter("a", Tool.ParameterType.INTEGER, "First number", true, null), new Tool.Parameter("b", Tool.ParameterType.INTEGER, "Second number", true, null) ), (input) -> { // Parse input JSON and compute result return Tool.Result.text("42"); } ); // Tool with metadata (e.g., return directly without LLM processing) Tool directTool = Tool.create( "lookup", "Looks up data directly", Tool.Metadata.create(true), // returnDirect = true (input) -> Tool.Result.text("Direct result") ); - Kotlin
-
// Simple tool with no parameters val greetTool = Tool.of( name = "greet", description = "Greets the user" ) { _ -> Tool.Result.text("Hello!") } // Tool with parameters val addTool = Tool.of( name = "add", description = "Adds two numbers together", inputSchema = Tool.InputSchema.of( Tool.Parameter("a", Tool.ParameterType.INTEGER, "First number", true, null), Tool.Parameter("b", Tool.ParameterType.INTEGER, "Second number", true, null) ) ) { input -> // Parse input JSON and compute result Tool.Result.text("42") } // Tool with metadata val directTool = Tool.of( name = "lookup", description = "Looks up data directly", metadata = Tool.Metadata(returnDirect = true) ) { _ -> Tool.Result.text("Direct result") }
Creating Tools from Annotated Methods
Embabel provides @LlmTool and @LlmTool.Param annotations for creating tools from annotated methods.
This approach is similar to Spring AI’s @Tool but uses Embabel’s framework-agnostic abstractions.
- Java
-
public class MathService { @LlmTool(description = "Adds two numbers together") public int add( @LlmTool.Param(description = "First number") int a, @LlmTool.Param(description = "Second number") int b) { return a + b; } @LlmTool(description = "Multiplies two numbers") public int multiply( @LlmTool.Param(description = "First number") int a, @LlmTool.Param(description = "Second number") int b) { return a * b; } } // Create tools from all annotated methods on an instance List<Tool> mathTools = Tool.fromInstance(new MathService()); // Or safely create tools (returns empty list if no annotations found) List<Tool> tools = Tool.safelyFromInstance(someObject); - Kotlin
-
class MathService { @LlmTool(description = "Adds two numbers together") fun add( @LlmTool.Param(description = "First number") a: Int, @LlmTool.Param(description = "Second number") b: Int, ): Int = a + b @LlmTool(description = "Multiplies two numbers") fun multiply( @LlmTool.Param(description = "First number") a: Int, @LlmTool.Param(description = "Second number") b: Int, ): Int = a * b } // Create tools from all annotated methods on an instance val mathTools = Tool.fromInstance(MathService()) // Or safely create tools (returns empty list if no annotations found) val tools = Tool.safelyFromInstance(someObject)
The @LlmTool annotation supports:
-
name: Tool name (defaults to method name if empty) -
description: Description of what the tool does (required) -
returnDirect: Whether to return the result directly without further LLM processing
The @LlmTool.Param annotation supports:
-
description: Description of the parameter (helps the LLM understand what to provide) -
required: Whether the parameter is required (defaults to true)
Adding Framework-Agnostic Tools via PromptRunner
Use withTool() or withTools() to add framework-agnostic tools to a PromptRunner:
- Java
-
// Add a single tool Tool calculatorTool = Tool.create("calculate", "Performs calculations", (input) -> Tool.Result.text("Result: 42")); context.ai() .withDefaultLlm() .withTool(calculatorTool) .createObject("Calculate 6 * 7", MathResult.class); // Add tools from annotated methods List<Tool> mathTools = Tool.fromInstance(new MathService()); context.ai() .withDefaultLlm() .withTools(mathTools) .createObject("Add 5 and 3", MathResult.class); // Combine with other tool sources context.ai() .withDefaultLlm() .withToolGroup(CoreToolGroups.WEB) // Tool group .withToolObject(domainObject) // Spring AI @Tool methods .withTools(mathTools) // Framework-agnostic tools .createObject("Research and calculate", Report.class); - Kotlin
-
// Add a single tool val calculatorTool = Tool.of("calculate", "Performs calculations") { _ -> Tool.Result.text("Result: 42") } context.ai() .withDefaultLlm() .withTool(calculatorTool) .createObject("Calculate 6 * 7", MathResult::class.java) // Add tools from annotated methods val mathTools = Tool.fromInstance(MathService()) context.ai() .withDefaultLlm() .withTools(mathTools) .createObject("Add 5 and 3", MathResult::class.java) // Combine with other tool sources context.ai() .withDefaultLlm() .withToolGroup(CoreToolGroups.WEB) // Tool group .withToolObject(domainObject) // Spring AI @Tool methods .withTools(mathTools) // Framework-agnostic tools .createObject("Research and calculate", Report::class.java)
Tool Results
Tools return Tool.Result which can be one of three types:
// Text result (most common)
Tool.Result.text("The answer is 42")
// Result with an artifact (e.g., generated file, image)
Tool.Result.withArtifact("Generated report", reportBytes)
// Error result
Tool.Result.error("Failed to process request", exception)
When to Use Each Approach
| Approach | Use When |
|---|---|
Spring AI |
You’re comfortable with Spring AI and want IDE support for tool annotations on domain objects |
|
You need programmatic tool creation, want framework independence, or are creating tools dynamically |
|
You prefer annotation-based tools but want Embabel’s framework-agnostic abstractions |
Tool Groups |
You need to organize related tools, use MCP servers, or control tool availability at deployment time |
3.9.4. Agentic Tools
An agentic tool is a tool that uses an LLM to orchestrate other tools. Unlike a regular tool which executes deterministic logic, an agentic tool delegates to an LLM that decides which sub-tools to call based on a prompt.
This pattern is useful for encapsulating a mini-orchestration as a single tool that can be used in larger workflows.
When to Use Agentic Tools
Agentic tools are appropriate when:
-
You have a set of related tools that work together
-
The orchestration logic is simple enough that an LLM can handle it with a prompt
-
You want to expose a high-level capability as a single tool
| For complex workflows with defined outputs, branching logic, loops, or state management, use Embabel’s GOAP planner, Utility AI, or @State workflows instead. These provide deterministic, typesafe planning that is far more powerful and predictable than LLM-driven orchestration. |
Creating Agentic Tools
Create agentic tools using the constructor and fluent with* methods:
- Java
-
// Create sub-tools Tool addTool = Tool.create("add", "Adds two numbers", input -> { // Parse JSON input and compute result return Tool.Result.text("5"); }); Tool multiplyTool = Tool.create("multiply", "Multiplies two numbers", input -> { return Tool.Result.text("6"); }); // Create the agentic tool AgenticTool mathOrchestrator = new AgenticTool("math-orchestrator", "Orchestrates math operations") .withTools(addTool, multiplyTool) .withLlm(LlmOptions.withModel("gpt-4")) .withSystemPrompt("Use the available tools to solve the given math problem"); // Use it like any other tool context.ai() .withDefaultLlm() .withTool(mathOrchestrator) .generateText("What is 5 + 3 * 2?"); - Kotlin
-
// Create sub-tools val addTool = Tool.of("add", "Adds two numbers") { input -> // Parse JSON input and compute result Tool.Result.text("5") } val multiplyTool = Tool.of("multiply", "Multiplies two numbers") { input -> Tool.Result.text("6") } // Create the agentic tool val mathOrchestrator = AgenticTool("math-orchestrator", "Orchestrates math operations") .withTools(addTool, multiplyTool) .withLlm(LlmOptions(model = "gpt-4")) .withSystemPrompt("Use the available tools to solve the given math problem") // Use it like any other tool context.ai() .withDefaultLlm() .withTool(mathOrchestrator) .generateText("What is 5 + 3 * 2?")
The withSystemPrompt call is optional. By default, AgenticTool generates a system prompt from the tool’s description: "You are an intelligent agent that can use tools to help you complete tasks. Use the provided tools to perform the following task: {description}". Only call withSystemPrompt if you need custom orchestration instructions.
|
Defining Input Parameters
You must define input parameters for your AgenticTool so the LLM knows what arguments to pass when calling it. Without parameters, the LLM won’t know what input format to use.
|
Use the withParameter method with Tool.Parameter factory methods for concise parameter definitions:
- Java
-
// Research tool that requires a topic parameter AgenticTool researcher = new AgenticTool("researcher", "Research a topic thoroughly") .withParameter(Tool.Parameter.string("topic", "The topic to research")) .withToolObjects(new SearchTools(), new SummarizerTools()); // Calculator with multiple parameters AgenticTool calculator = new AgenticTool("smart-calculator", "Perform complex calculations") .withParameter(Tool.Parameter.string("expression", "Mathematical expression to evaluate")) .withParameter(Tool.Parameter.integer("precision", "Decimal places for result", false)) // optional .withToolObject(new MathTools()); - Kotlin
-
// Research tool that requires a topic parameter val researcher = AgenticTool("researcher", "Research a topic thoroughly") .withParameter(Tool.Parameter.string("topic", "The topic to research")) .withToolObjects(SearchTools(), SummarizerTools()) // Calculator with multiple parameters val calculator = AgenticTool("smart-calculator", "Perform complex calculations") .withParameter(Tool.Parameter.string("expression", "Mathematical expression to evaluate")) .withParameter(Tool.Parameter.integer("precision", "Decimal places for result", required = false)) // optional .withToolObject(MathTools())
Available parameter factory methods:
-
Tool.Parameter.string(name, description, required?)- String parameter -
Tool.Parameter.integer(name, description, required?)- Integer parameter -
Tool.Parameter.double(name, description, required?)- Floating-point parameter
All factory methods default to required = true. Set required = false for optional parameters.
Creating Agentic Tools from Annotated Objects
Use withToolObject or withToolObjects to add tools from objects with @LlmTool-annotated methods:
- Java
-
// Tool classes with @LlmTool methods public class SearchTools { @LlmTool(description = "Search the web") public String search(String query) { return "Results for: " + query; } } public class CalculatorTools { @LlmTool(description = "Add two numbers") public int add(int a, int b) { return a + b; } @LlmTool(description = "Multiply two numbers") public int multiply(int a, int b) { return a * b; } } // Create agentic tool with tools from multiple objects // Uses default system prompt based on description AgenticTool assistant = new AgenticTool("assistant", "Multi-capability assistant") .withToolObjects(new SearchTools(), new CalculatorTools()); // With LLM options and custom system prompt AgenticTool smartAssistant = new AgenticTool("smart-assistant", "Smart assistant") .withToolObjects(new SearchTools(), new CalculatorTools()) .withLlm(LlmOptions.withModel("gpt-4")) .withSystemPrompt("Use tools intelligently"); - Kotlin
-
// Tool classes with @LlmTool methods class SearchTools { @LlmTool(description = "Search the web") fun search(query: String): String = "Results for: $query" } class CalculatorTools { @LlmTool(description = "Add two numbers") fun add(a: Int, b: Int): Int = a + b @LlmTool(description = "Multiply two numbers") fun multiply(a: Int, b: Int): Int = a * b } // Create agentic tool with tools from multiple objects // Uses default system prompt based on description val assistant = AgenticTool("assistant", "Multi-capability assistant") .withToolObjects(SearchTools(), CalculatorTools()) // With LLM options and custom system prompt val smartAssistant = AgenticTool("smart-assistant", "Smart assistant") .withToolObjects(SearchTools(), CalculatorTools()) .withLlm(LlmOptions(model = "gpt-4")) .withSystemPrompt("Use tools intelligently")
Objects without @LlmTool methods are silently ignored, allowing you to mix objects safely.
Agentic Tools with Spring Dependency Injection
Agentic tools can encapsulate stateful services via dependency injection:
@Component
class ResearchOrchestrator(
private val webSearchService: WebSearchService,
private val summarizerService: SummarizerService,
) {
@LlmTool(description = "Search the web for information")
fun search(query: String): List<SearchResult> =
webSearchService.search(query)
@LlmTool(description = "Summarize text content")
fun summarize(content: String): String =
summarizerService.summarize(content)
}
// In your configuration
@Configuration
class ToolConfiguration {
@Bean
fun researchTool(orchestrator: ResearchOrchestrator): AgenticTool =
AgenticTool("research-assistant", "Research topics using web search and summarization")
.withToolObject(orchestrator)
.withLlm(LlmOptions(role = "smart"))
// Uses default system prompt based on description
}
How Agentic Tools Execute
When an agentic tool’s call() method is invoked:
-
The tool retrieves the current
AgentProcesscontext -
It configures a
PromptRunnerwith the specifiedLlmOptions -
It adds all sub-tools to the prompt runner
-
It executes the prompt with the input, allowing the LLM to orchestrate the sub-tools
-
The final LLM response is returned as the tool result
This means agentic tools create a nested LLM interaction: the outer LLM decides to call the agentic tool, then the inner LLM orchestrates the sub-tools.
Modifying Agentic Tools
Use the with* methods to create modified copies:
val base = AgenticTool("base", "Base orchestrator")
.withTools(tool1)
.withSystemPrompt("Original prompt")
// Create copies with modifications
val withNewLlm = base.withLlm(LlmOptions(model = "gpt-4"))
val withMoreTools = base.withTools(tool2, tool3)
val withNewPrompt = base.withSystemPrompt("Updated prompt")
// Add input parameters
val withParams = base.withParameter(Tool.Parameter.string("query", "Search query"))
// Add tools from an object with @LlmTool methods
val withAnnotatedTools = base.withToolObject(calculatorService)
// Add tools from multiple objects
val withMultipleObjects = base.withToolObjects(searchService, calculatorService)
// Dynamic system prompt based on AgentProcess context
val withDynamicPrompt = base.withSystemPromptCreator { agentProcess ->
val userId = agentProcess.blackboard.get("userId", String::class.java)
"Process requests for user $userId using the available tools"
}
The available modification methods are:
-
withParameter(Tool.Parameter): Add an input parameter (useTool.Parameter.string(),.integer(),.double()) -
withLlm(LlmOptions): Set LLM configuration -
withTools(vararg Tool): Add additional Tool instances -
withToolObject(Any): Add tools from an object with@LlmToolmethods -
withToolObjects(vararg Any): Add tools from multiple annotated objects -
withSystemPrompt(String): Set a fixed system prompt -
withSystemPromptCreator((AgentProcess) → String): Set a dynamic prompt based on runtime context
Migration from Other Frameworks
If you’re coming from frameworks like LangChain or Google ADK, AgenticTool provides a familiar pattern similar to their "supervisor" architectures:
| Framework | Pattern | Embabel Equivalent |
|---|---|---|
LangChain/LangGraph |
Supervisor agent with worker agents |
|
Google ADK |
Coordinator with |
|
The key differences:
-
Tool-centric: Embabel’s agentic tools operate at the tool level, not the agent level. They’re lightweight and can be mixed freely with regular tools.
-
Simpler model: No graph-based workflows or explicit Sequential/Parallel/Loop patterns—just LLM-driven orchestration.
-
Composable: An agentic tool is still "just a tool" that can be used anywhere tools are accepted.
However, for anything beyond simple orchestration, Embabel offers far more powerful alternatives:
| Scenario | Use This Instead |
|---|---|
Business processes with defined outputs |
GOAP planner - deterministic, goal-oriented planning with preconditions and effects |
Exploration and event-driven systems |
Utility AI - selects highest-value action at each step |
Branching, looping, or stateful workflows |
@State workflows - typesafe state machines with GOAP planning within each state |
These provide deterministic, typesafe planning that is far more predictable and powerful than supervisor-style LLM orchestration.
Use AgenticTool for simple cases or as a migration stepping stone; graduate to GOAP, Utility, or @State for production workflows where predictability matters.
For supervisor-style orchestration with typed outputs and full blackboard state management, see SupervisorInvocation. It operates at a higher level than AgenticTool, orchestrating @Action methods rather than Tool instances, and produces typed goal objects with currying support.
|
3.10. Structured Prompt Elements
Embabel provides a number of ways to structure and manage prompt content.
Prompt contributors are a fundamental way to structure and inject content into LLM prompts. You don’t need to use them—you can simply build your prompts as strings—but they can be useful to achieve consistency and reuse across multiple actions or even across multiple agents using the same domain objects.
Prompt contributors implement the PromptContributor interface and provide text that gets included in the final prompt sent to the LLM.
By default the text will be included in the system prompt message.
3.10.1. The PromptContributor Interface and LlmReference Subinterface
All prompt contributors implement the PromptContributor interface with a contribution() method that returns a string to be included in the prompt.
Add PromptContributor instances to a PromptRunner using the withPromptContributor() method.
A subinterface of PromptContributor is LlmReference.
An LlmReference is a prompt contributor that can also provide tools via annotated @Tool methods.
So that tool naming can be disambiguated, an LlmReference must also include name and description metadata.
Add LlmReference instances to a PromptRunner using the withReference() method.
Use LlmReference if:
-
You want to provide both prompt content and tools from the same object
-
You want to provide specific instructions on how to use these tools, beyond the individual tool descriptions
-
Your data may be best exposed as either prompt content or tools, depending on the context. For example, if you have a list of 10 items you might just put in the prompt and say "Here are all the items: …". If you have a list of 10,000 objects, you would include advice to use the tools to query them.
An LlmReference is somewhat similar to a Claude Skill.
|
LlmReference instances can be created programmatically or defined in YML using LlmReferenceProvider implementations.
For example, you could define a references.yml file in this format:
- fqn: com.embabel.coding.tools.git.GitHubRepository
url: https://github.com/embabel/embabel-agent-examples.git
description: Embabel examples Repository
- fqn: com.embabel.coding.tools.git.GitHubRepository
url: https://github.com/embabel/java-agent-template.git
description: >
Java agent template Repository: Simplest Java example with Maven
Can be used as a GitHub template
- fqn: com.embabel.coding.tools.git.GitHubRepository
url: https://github.com/embabel/embabel-agent.git
description: >
Embabel agent framework implementation repo: Look to check code under embabel-agent-api
- fqn: com.embabel.coding.tools.api.ApiReferenceProvider
name: embabel-agent
description: Embabel Agent API
acceptedPackages:
- com.embabel.agent
- com.embabel.common
The fqn property specifies the fully qualified class name of the LlmReferenceProvider implementation.
This enables you to define your own LlmReferenceProvider implementations.
Out of the box, Embabel provides:
-
com.embabel.agent.api.common.reference.LiteralText: Text innotes -
com.embabel.agent.api.common.reference.SpringResource: Contents of the given Spring resource path -
com.embabel.agent.api.common.reference.WebPage: Content of the given web page, if it can be fetched -
com.embabel.coding.tools.git.GitHubRepository: GitHub repositories (embabel-agent-codemodule) -
com.embabel.coding.tools.api.ApiReferenceProvider: API from classpath (embabel-agent-codemodule)
You can parse your YML files into List<LlmReference> using the LlmReferenceProviders.fromYml method.
The resource argument is a Spring resource specification.
Thus LlmReferenceProviders.fromYml("references.yml") will load references.yml under src/main/resources/
3.10.2. Built-in Convenience Classes
Embabel provides several convenience classes that implement PromptContributor for common use cases.
These are optional utilities - you can always implement the interface directly for custom needs.
Persona
The Persona class provides a structured way to define an AI agent’s personality and behavior:
val persona = Persona.create(
name = "Alex the Analyst",
persona = "A detail-oriented data analyst with expertise in financial markets",
voice = "Professional yet approachable, uses clear explanations",
objective = "Help users understand complex financial data through clear analysis"
)
This generates a prompt contribution like:
You are Alex the Analyst. Your persona: A detail-oriented data analyst with expertise in financial markets. Your objective is Help users understand complex financial data through clear analysis. Your voice: Professional yet approachable, uses clear explanations.
RoleGoalBackstory
The RoleGoalBackstory class follows the Crew AI pattern and is included for users migrating from that framework:
var agent = RoleGoalBackstory.withRole("Senior Software Engineer")
.andGoal("Write clean, maintainable code")
.andBackstory("10+ years experience in enterprise software development")
This generates:
Role: Senior Software Engineer Goal: Write clean, maintainable code Backstory: 10+ years experience in enterprise software development
3.10.3. Custom PromptContributor Implementations
You can create custom prompt contributors by implementing the interface directly. This gives you complete control over the prompt content:
class CustomSystemPrompt(private val systemName: String) : PromptContributor {
override fun contribution(): String {
return "System: $systemName - Current time: ${LocalDateTime.now()}"
}
}
class ConditionalPrompt(
private val condition: () -> Boolean,
private val trueContent: String,
private val falseContent: String
) : PromptContributor {
override fun contribution(): String {
return if (condition()) trueContent else falseContent
}
}
3.10.4. Examples from embabel-agent-examples
The embabel-agent-examples repository demonstrates various agent development patterns and Spring Boot integration approaches for building AI agents with Embabel.
3.10.5. Best Practices
-
Keep prompt contributors focused and single-purpose
-
Use the convenience classes (
Persona,RoleGoalBackstory) when they fit your needs -
Implement custom
PromptContributorclasses for domain-specific requirements -
Consider using dynamic contributors for context-dependent content
-
Test your prompt contributions to ensure they produce the desired LLM behavior
3.11. Templates
Embabel supports Jinja templates for generating prompts.
You do this via the PromptRunner.withTemplate(String) method.
This method takes a Spring resource path to a Jinja template.
The default location is under classpath:/prompts/ and the .jinja extension is added automatically.
You can also specify a full resource path with Spring resource conventions.
Once you have specified the template, you can create objects using a model map.
An example:
val distinctFactualAssertions = context.ai()
.withLlm(properties.deduplicationLlm())
// Jinjava template from classpath at prompts/factchecker/consolidate_assertions.jinja
.withTemplate("factchecker/consolidate_assertions")
.createObject(
DistinctFactualAssertions.class,
Map.of(
"assertions", allAssertions,
"reasoningWordCount", properties.reasoningWordCount()
)
);
| Don’t rush to externalize prompts. In modern languages with multi-line strings, it’s often easier to keep prompts in the codebase. Externalizing them can sacrifice type safety and lead to complexity and maintenance challenges. |
3.12. RAG (Retrieval-Augmented Generation)
Retrieval-Augmented Generation (RAG) is a technique that enhances LLM responses by retrieving relevant information from a knowledge base before generating answers. This grounds LLM outputs in specific, verifiable sources rather than relying solely on training data.
For more background on RAG concepts, see:
Embabel Agent provides RAG support through the LlmReference interface, which allows you to attach references (including RAG stores) to LLM calls.
The key classes are ToolishRag for exposing search operations as LLM tools, and SearchOperations for the underlying search functionality.
3.12.1. Agentic RAG Architecture
Unlike traditional RAG implementations that perform a single retrieval step, Embabel Agent’s RAG is entirely agentic and tool-based. The LLM has full control over the retrieval process:
-
Autonomous Search: The LLM decides when to search, what queries to use, and how many results to retrieve
-
Iterative Refinement: The LLM can perform multiple searches with different queries until it finds relevant information
-
Cross-Reference Discovery: The LLM can follow references, expand chunks to see surrounding context, and zoom out to parent sections
-
HyDE Support: The LLM can generate hypothetical documents (HyDE queries) to improve semantic search results
This agentic approach produces better results than single-shot RAG because the LLM can:
-
Start with a broad search and narrow down
-
Try different phrasings if initial queries return poor results
-
Expand promising results to get more context
-
Combine information from multiple chunks
3.12.2. Facade Pattern for Safe Tool Exposure
Embabel Agent uses a facade pattern to expose RAG capabilities safely and consistently across different store implementations.
The ToolishRag class acts as a facade that:
-
Inspects Store Capabilities: Examines which
SearchOperationssubinterfaces the store implements -
Exposes Appropriate Tools: Only creates tool wrappers for supported operations
-
Provides Consistent Interface: All tools use the same parameter patterns regardless of underlying store
override fun toolInstances(): List<Any> =
buildList {
if (searchOperations is VectorSearch) {
add(VectorSearchTools(searchOperations))
}
if (searchOperations is TextSearch) {
add(TextSearchTools(searchOperations))
}
if (searchOperations is ResultExpander) {
add(ResultExpanderTools(searchOperations))
}
if (searchOperations is RegexSearchOperations) {
add(RegexSearchTools(searchOperations))
}
}
This means:
-
A Lucene store exposes vector search, text search, regex search, AND result expansion tools
-
A Spring AI VectorStore adapter exposes only vector search tools
-
A basic text-only store exposes only text search tools
-
A directory-based text search exposes text search and regex search
The LLM sees only the tools that actually work with the configured store, preventing runtime errors from unsupported operations.
3.12.3. Getting Started
To use RAG in your Embabel Agent application, add the rag-core module and a store implementation to your pom.xml:
<dependency>
<groupId>com.embabel.agent</groupId>
<artifactId>embabel-agent-rag-lucene</artifactId>
<version>${embabel-agent.version}</version>
</dependency>
<dependency>
<groupId>com.embabel.agent</groupId>
<artifactId>embabel-agent-rag-tika</artifactId>
<version>${embabel-agent.version}</version>
</dependency>
The embabel-agent-rag-lucene module provides Lucene-based vector and text search.
The embabel-agent-rag-tika module provides Apache Tika integration for parsing various document formats.
3.12.4. Our Model
Embabel Agent uses a hierarchical content model that goes beyond traditional flat chunk storage:
Content Elements
The ContentElement interface is the supertype for all content in the RAG system.
Key subtypes include:
-
ContentRoot/NavigableDocument: The root of a document hierarchy, with a required URI and title -
Section: A hierarchical division of content with a title -
ContainerSection: A section containing other sections -
LeafSection: A section containing actual text content -
Chunk: Traditional RAG text chunks, created by splittingLeafSectioncontent
Chunks
Chunk is the primary unit for vector search.
Each chunk:
-
Contains a
textfield with the content -
Has a
parentIdlinking to its source section -
Includes
metadatawith information about its origin (root document, container section, leaf section) -
Can compute its
pathFromRootthrough the document hierarchy
This hierarchical model enables advanced RAG capabilities like "zoom out" to parent sections or expansion to adjacent chunks.
3.12.5. SearchOperations
SearchOperations is the tag interface for search functionality.
Concrete implementations implement one or more subinterfaces based on their capabilities.
This design allows stores to implement only what’s natural and efficient for them—a vector database need not pretend to support full-text search, and a text search engine need not fake vector similarity.
VectorSearch
Classic semantic vector search:
interface VectorSearch : SearchOperations {
fun <T : Retrievable> vectorSearch(
request: TextSimilaritySearchRequest,
clazz: Class<T>,
): List<SimilarityResult<T>>
}
TextSearch
Full-text search using Lucene query syntax:
interface TextSearch : SearchOperations {
fun <T : Retrievable> textSearch(
request: TextSimilaritySearchRequest,
clazz: Class<T>,
): List<SimilarityResult<T>>
}
Supported query syntax includes:
-
+term- term must appear -
-term- term must not appear -
"phrase"- exact phrase match -
term*- prefix wildcard -
term~- fuzzy match
ResultExpander
Expand search results to surrounding context:
interface ResultExpander : SearchOperations {
fun expandResult(
id: String,
method: Method,
elementsToAdd: Int,
): List<ContentElement>
}
Expansion methods:
-
SEQUENCE- expand to previous and next chunks -
ZOOM_OUT- expand to enclosing section
RegexSearchOperations
Pattern-based search across content:
interface RegexSearchOperations : SearchOperations {
fun <T : Retrievable> regexSearch(
regex: Regex,
topK: Int,
clazz: Class<T>,
): List<SimilarityResult<T>>
}
Useful for finding specific patterns like error codes, identifiers, or structured content that doesn’t match well with semantic or keyword search.
3.12.6. ToolishRag
ToolishRag is an LlmReference that exposes SearchOperations as LLM tools.
This gives the LLM fine-grained control over RAG searches.
Configuration
Create a ToolishRag by wrapping your SearchOperations:
public ChatActions(SearchOperations searchOperations) {
this.toolishRag = new ToolishRag(
"sources",
"Sources for answering user questions",
searchOperations
);
}
Using with LLM Calls
Attach ToolishRag to an LLM call using .withReference():
@Action(canRerun = true, trigger = UserMessage.class)
void respond(Conversation conversation, ActionContext context) {
var assistantMessage = context.ai()
.withLlm(properties.chatLlm())
.withReference(toolishRag)
.withTemplate("ragbot")
.respondWithSystemPrompt(conversation, Map.of(
"properties", properties
));
context.sendMessage(conversation.addMessage(assistantMessage));
}
Based on the capabilities of the underlying SearchOperations, ToolishRag exposes:
-
VectorSearchTools:
vectorSearch(query, topK, threshold)- semantic similarity search -
TextSearchTools:
textSearch(query, topK, threshold)- BM25 full-text search with Lucene syntax -
RegexSearchTools:
regexSearch(regex, topK)- pattern-based search using regular expressions -
ResultExpanderTools:
broadenChunk(chunkId, chunksToAdd)- expand to adjacent chunks,zoomOut(id)- expand to parent section
The LLM autonomously decides when to use these tools based on user queries.
ToolishRag lifecycle
It is safe to create a ToolishRag instance and reuse across many LLM calls.
However, instances are not expensive to create, so you can create a new instance per LLM call.
You might choose to do this if you provide a ResultListener
that will collect queries and results for logging or analysis: for example, to track which queries were most useful for answering user questions and the complexity in terms of number of searches performed.
This can be useful for implementing a learning feedback loop, for example to discern which queries performed badly, indicating that content such as documentation needs to be enhanced.
3.12.7. Ingestion
Document Parsing with Tika
Embabel Agent uses Apache Tika for document parsing. TikaHierarchicalContentReader reads various formats (Markdown, HTML, PDF, Word, etc.) and extracts a hierarchical structure:
@ShellMethod("Ingest URL or file path")
String ingest(@ShellOption(defaultValue = "./data/document.md") String location) {
var uri = location.startsWith("http://") || location.startsWith("https://")
? location
: Path.of(location).toAbsolutePath().toUri().toString();
var ingested = NeverRefreshExistingDocumentContentPolicy.INSTANCE
.ingestUriIfNeeded(
luceneSearchOperations,
new TikaHierarchicalContentReader(),
uri
);
return ingested != null ?
"Ingested document with ID: " + ingested :
"Document already exists, no ingestion performed.";
}
Chunking Configuration
Content is split into chunks with configurable parameters:
ragbot:
chunker-config:
max-chunk-size: 800
overlap-size: 100
Configuration options:
-
maxChunkSize- Maximum characters per chunk (default: 1500) -
overlapSize- Character overlap between consecutive chunks (default: 200) -
includeSectionTitleInChunk- Include section title in chunk text (default: true)
Using Docling for Markdown Conversion
While we strongly believe you should write your Gen AI applications in Java, ingestion is more in the realm of data science, and Python is indisputably strong in this area.
For complex documents like PDFs, consider using Docling to convert to Markdown first:
docling https://example.com/document.pdf --from pdf --to md --output ./data
Markdown is easier to parse hierarchically and produces better chunks than raw PDF extraction.
3.12.8. Supported Stores
Embabel Agent provides several RAG store implementations:
Lucene (embabel-agent-rag-lucene)
Full-featured store with vector search, text search, and result expansion. Supports both in-memory and file-based persistence:
@Bean
LuceneSearchOperations luceneSearchOperations(
ModelProvider modelProvider,
RagbotProperties properties) {
var embeddingService = modelProvider.getEmbeddingService(
DefaultModelSelectionCriteria.INSTANCE);
return LuceneSearchOperations
.withName("docs")
.withEmbeddingService(embeddingService)
.withChunkerConfig(properties.chunkerConfig())
.withIndexPath(Paths.get("./.lucene-index")) // File persistence
.buildAndLoadChunks();
}
Omit .withIndexPath() for in-memory only storage.
Neo4j
Graph database store for RAG (available in separate modules embabel-agent-rag-neo-drivine and embabel-agent-rag-neo-ogm).
Ideal when you need graph relationships between content elements.
Spring AI VectorStore (SpringVectorStoreVectorSearch)
Adapter that wraps any Spring AI VectorStore, enabling use of any vector database Spring AI supports:
class SpringVectorStoreVectorSearch(
private val vectorStore: VectorStore,
) : VectorSearch {
override fun <T : Retrievable> vectorSearch(
request: TextSimilaritySearchRequest,
clazz: Class<T>,
): List<SimilarityResult<T>> {
val searchRequest = SearchRequest
.builder()
.query(request.query)
.similarityThreshold(request.similarityThreshold)
.topK(request.topK)
.build()
val results = vectorStore.similaritySearch(searchRequest)
// ... convert results
}
}
This allows integration with Pinecone, Weaviate, Milvus, Chroma, and other stores via Spring AI.
3.12.9. Implementing Your Own RAG Store
To implement a custom RAG store, implement only the SearchOperations subinterfaces that are natural and efficient for your store.
This is a key design principle: stores should only implement what they can do well.
For example:
-
A vector database like Pinecone might implement only
VectorSearchsince that’s its strength -
A full-text search engine might implement
TextSearchandRegexSearchOperations -
A hierarchical document store might add
ResultExpanderfor context expansion -
A full-featured store like Lucene can implement all interfaces
The ToolishRag facade automatically exposes only the tools that your store supports.
This means you don’t need to provide stub implementations or throw "not supported" exceptions—simply don’t implement interfaces that don’t fit your store’s capabilities.
// A store that only supports vector search
class MyVectorOnlyStore : VectorSearch {
override fun <T : Retrievable> vectorSearch(
request: TextSimilaritySearchRequest,
clazz: Class<T>,
): List<SimilarityResult<T>> {
// Implement vector similarity search
}
}
// A store that supports both vector and text search
class MyFullTextStore : VectorSearch, TextSearch {
override fun <T : Retrievable> vectorSearch(
request: TextSimilaritySearchRequest,
clazz: Class<T>,
): List<SimilarityResult<T>> {
// Implement vector similarity search
}
override fun <T : Retrievable> textSearch(
request: TextSimilaritySearchRequest,
clazz: Class<T>,
): List<SimilarityResult<T>> {
// Implement full-text search
}
override val luceneSyntaxNotes: String = "Full Lucene syntax supported"
}
For ingestion support, extend ChunkingContentElementRepository to handle document storage and chunking.
3.12.10. Complete Example
See the rag-demo project for a complete working example including:
-
Lucene-based RAG store configuration
-
Document ingestion via Tika
-
Chatbot with RAG-powered responses
-
Jinja prompt templates for system prompts
-
Spring Shell commands for interactive testing === Building Chatbots
Chatbots are an important application of Gen AI, although far from the only use, especially in enterprise.
Unlike many other frameworks, Embabel does not maintain a conversation thread to do its core work. This is a good thing as it means that context compression is not required for most tasks.
If you want to build a chatbot you should use the Conversation interface explicitly, and expose a Chatbot bean, typically backed by action methods that handle UserMessage events.
3.12.11. Core Concepts
Long-Lived AgentProcess
An Embabel chatbot is backed by a long-lived AgentProcess that pauses between user messages. This design has important implications:
-
The same
AgentProcesscan respond to events besides user input -
The blackboard maintains state across the entire session
-
Actions can be triggered by user messages, system events, or other objects added to the blackboard
-
It’s a working context rather than just a chat session
When a user sends a message, it’s added to the blackboard as a UserMessage. The AgentProcess then runs, selects an appropriate action to handle it, and pauses again waiting for the next event.
Utility AI for Chatbots
Utility AI is often the best approach for chatbots. Instead of defining a fixed flow, you define multiple actions with costs, and the planner selects the highest-value action to respond to each message.
This allows:
-
Multiple response strategies (e.g., RAG search, direct answer, clarification request)
-
Dynamic behavior based on context
-
Easy extensibility by adding new action methods
Goals in Chatbots
Typically, chatbot agents do not need a goal. The agent process simply waits for user messages and responds to them indefinitely.
However, you can define a goal if you want to ensure the conversation terminates and the AgentProcess completes rather than waiting forever. This is useful for:
-
Transactional conversations (e.g., completing a booking)
-
Wizard-style flows with a defined endpoint
-
Conversations that should end after collecting specific information
3.12.12. Key Interfaces
Chatbot
The Chatbot interface manages multiple chat sessions:
interface Chatbot {
fun createSession(
user: User?,
outputChannel: OutputChannel,
systemMessage: String? = null,
): ChatSession
fun findSession(conversationId: String): ChatSession?
}
ChatSession
Each session represents an ongoing conversation:
interface ChatSession {
val outputChannel: OutputChannel
val user: User?
val conversation: Conversation
val processId: String?
fun onUserMessage(userMessage: UserMessage)
fun isFinished(): Boolean
}
Conversation
The Conversation interface holds the message history:
interface Conversation : StableIdentified {
val messages: List<Message>
fun addMessage(message: Message): Message
fun lastMessageIfBeFromUser(): UserMessage?
}
Message types include:
-
UserMessage- messages from the user (supports multimodal content) -
AssistantMessage- responses from the chatbot -
SystemMessage- system-level instructions
3.12.13. Building a Chatbot
Step 1: Create Action Methods
Define action methods in an @EmbabelComponent that respond to user messages using the trigger parameter:
@EmbabelComponent
public class ChatActions {
private final ToolishRag toolishRag;
private final RagbotProperties properties;
public ChatActions(
SearchOperations searchOperations,
RagbotProperties properties) {
this.toolishRag = new ToolishRag(
"sources",
"Sources for answering user questions",
searchOperations
);
this.properties = properties;
}
@Action(canRerun = true, trigger = UserMessage.class) (1) (2)
void respond(
Conversation conversation, (3)
ActionContext context) {
var assistantMessage = context.ai()
.withLlm(properties.chatLlm())
.withReference(toolishRag)
.withTemplate("ragbot")
.respondWithSystemPrompt(conversation, Map.of(
"properties", properties
));
context.sendMessage(conversation.addMessage(assistantMessage)); (4)
}
}
| 1 | trigger = UserMessage.class - action is invoked when a UserMessage is the last object added to the blackboard |
| 2 | canRerun = true - action can be executed multiple times (for each user message) |
| 3 | Conversation parameter is automatically injected from the blackboard |
| 4 | context.sendMessage() sends the response to the output channel |
Step 2: Configure the Chatbot Bean
Use AgentProcessChatbot.utilityFromPlatform() to create a utility-based chatbot that discovers all available actions:
@Configuration
class ChatConfiguration {
@Bean
Chatbot chatbot(AgentPlatform agentPlatform) {
return AgentProcessChatbot.utilityFromPlatform( (1)
agentPlatform, (2)
new Verbosity().showPrompts() (3)
);
}
}
| 1 | Creates a chatbot using Utility AI planning to select the best action |
| 2 | Discovers all @Action methods from @EmbabelComponent classes on the platform |
| 3 | Optional Verbosity configuration for debugging prompts |
Step 3: Use the Chatbot
Interact with the chatbot through its session interface:
ChatSession session = chatbot.createSession(user, outputChannel, null); (1)
session.onUserMessage(new UserMessage("What does this document say about taxes?")); (2)
// Response is automatically sent to the outputChannel (3)
| 1 | Create a new session for a user with an output channel |
| 2 | Send a user message - triggers the agent to select and run an action |
| 3 | Responses from actions are sent to the outputChannel |
3.12.14. How Message Triggering Works
When you specify trigger = UserMessage.class on an action:
-
The chatbot adds the
UserMessageto both theConversationand theAgentProcessblackboard -
The planner evaluates all actions whose trigger conditions are satisfied
-
For utility planning, the action with the highest value (lowest cost) is selected
-
The action method receives the
Conversation(with the new message) via parameter injection
This trigger-based approach means:
-
You can have multiple actions that respond to user messages with different costs
-
The planner picks the most appropriate response strategy
-
Actions can also be triggered by other event types (not just
UserMessage)
3.12.15. Dynamic Cost Methods
For more sophisticated action selection, use @Cost methods:
@Cost (1)
double dynamic(Blackboard bb) { (2)
return bb.getObjects().size() > 5 ? 100 : 10; (3)
}
@Action(canRerun = true,
trigger = UserMessage.class,
costMethod = "dynamic") (4)
void respond(Conversation conversation, ActionContext context) {
// ...
}
| 1 | @Cost marks this as a cost calculation method |
| 2 | Receives the Blackboard to inspect current state |
| 3 | Returns cost value - lower costs mean higher priority |
| 4 | costMethod links the action to the cost calculation method |
3.12.16. Prompt Templates
Chatbots typically use Jinja prompt templates rather than inline string prompts. This isn’t strictly necessary—simple chatbots can use regular string prompts built in code:
var assistantMessage = context.ai()
.withLlm(properties.chatLlm())
.withSystemPrompt("You are a helpful assistant. Answer questions concisely.") (1)
.respond(conversation.getMessages());
| 1 | Simple inline prompt - fine for basic chatbots |
However, production chatbots often need longer, more complex prompts for:
-
Personality and tone (personas)
-
Guardrails and safety instructions
-
Domain-specific objectives
-
Dynamic behavior based on configuration
For these cases, Jinja templates are the better choice:
var assistantMessage = context.ai()
.withLlm(properties.chatLlm())
.withReference(toolishRag)
.withTemplate("ragbot") (1)
.respondWithSystemPrompt(conversation, Map.of( (2)
"properties", properties,
"persona", properties.persona(),
"objective", properties.objective()
));
| 1 | Loads prompts/ragbot.jinja from resources |
| 2 | Template bindings - accessible in Jinja as properties.persona() etc. |
Templates allow:
-
Separation of prompt engineering from code
-
Dynamic persona and objective selection via configuration
-
Reusable prompt elements (guardrails, personalization)
-
Prompt iteration without code changes
Template Structure Example
A typical chatbot template structure from the rag-demo project:
prompts/
├── ragbot.jinja # Main entry point
├── elements/
│ ├── guardrails.jinja # Safety restrictions
│ └── personalization.jinja # Dynamic persona/objective loader
├── personas/
│ ├── clause.jinja # Legal expert persona
│ └── ...
└── objectives/
└── legal.jinja # Legal document analysis objective
The main template (ragbot.jinja) composes from reusable elements:
{% include "elements/guardrails.jinja" %} (1)
{% include "elements/personalization.jinja" %} (2)
| 1 | Include safety guardrails first |
| 2 | Then include persona and objective (which are dynamically selected) |
Guardrails define safety boundaries (elements/guardrails.jinja):
{# Safety and content guardrails for the ragbot. #}
DO NOT DISCUSS POLITICS OR CONTROVERSIAL TOPICS.
Personalization dynamically loads persona and objective (elements/personalization.jinja):
{% set persona_template = "personas/" ~ properties.persona() ~ ".jinja" %} (1)
{% include persona_template %}
{% set objective_template = "objectives/" ~ properties.objective() ~ ".jinja" %} (2)
{% include objective_template %}
| 1 | Build template path from properties.persona() (e.g., "clause" → "personas/clause.jinja") |
| 2 | Build template path from properties.objective() (e.g., "legal" → "objectives/legal.jinja") |
A persona template (personas/clause.jinja):
Your name is Clause.
You are a brilliant legal chatbot who excels at interpreting
legislation and legal documents.
An objective template (objectives/legal.jinja):
You are an authoritative interpreter of legislation and legal documents.
You are renowned for thoroughness and for never missing anything.
You answer questions definitively, in a clear and concise manner.
You cite relevant sections to back up your answers.
If you don't know, say you don't know.
NEVER FABRICATE ANSWERS.
You ground your answers in literal citations from the provided sources.
Always use the available tools. (1)
| 1 | Instructs the LLM to use RAG tools provided via withReference() |
This modular approach lets you:
-
Switch personas via
application.ymlwithout code changes -
Share guardrails across multiple chatbot configurations
-
Test different objectives independently
3.12.17. Advanced: State Management with @State
For complex chatbots that need to track state across messages, use @State classes. State classes are automatically managed by the agent framework:
-
State objects are persisted in the blackboard
-
Actions can depend on specific state being present
-
State transitions drive the conversation flow
Cross-reference the @State annotation documentation for details on:
-
Defining state classes
-
State-dependent actions
-
Nested state machines
3.12.18. Complete Example
See the rag-demo project for a complete chatbot implementation including:
-
ChatActions.java- Action methods responding to user messages -
ChatConfiguration.java- Chatbot bean configuration -
RagbotShell.java- Spring Shell integration for interactive testing -
Jinja templates for persona-driven prompts
-
RAG integration for document-grounded responses
To run the example:
./scripts/shell.sh
# In the shell:
ingest ./data/document.md
chat
> What does the document say about...
3.14. ProcessOptions
Agent processes can be configured with ProcessOptions.
ProcessOptions controls:
-
contextId: An identifier of any existing context in which the agent is running. -
blackboard: The blackboard to use for the agent. Allows starting from a particular state. -
test: Whether the agent is running in test mode. -
verbosity: The verbosity level of the agent. Allows fine grained control over logging prompts, LLM returns and detailed planning information -
control: Control options, determining whether the agent should be terminated as a last resort.EarlyTerminationPolicycan based on an absolute number of actions or a maximum budget. -
Delays: Both operations (actions) and tools can have delays. This is useful to avoid rate limiting.
3.15. The AgentPlatform
An AgentPlatform provides the ability to run agents in a specific environment.
This is an SPI interface, so multiple implementations are possible.
3.16. Invoking Embabel Agents
While many examples show Embabel agents being invoked via UserInput through the Embabel shell, they can also be invoked programmatically with strong typing.
This is usually how they’re used in web applications. It is also the most deterministic approach as code, rather than LLM assessment of user input, determines which agent is invoked and how.
3.16.1. Creating an AgentProcess Programmatically
You can create and execute agent processes directly using the AgentPlatform:
// Create an agent process with bindings
val agentProcess = agentPlatform.createAgentProcess(
agent = myAgent,
processOptions = ProcessOptions(),
bindings = mapOf("input" to userRequest)
)
// Start the process and wait for completion
val result = agentPlatform.start(agentProcess).get()
// Or run synchronously
val completedProcess = agentProcess.run()
val result = completedProcess.last<MyResultType>()
You can create processes and populate their input map from varargs objects:
// Create process from objects (like in web controllers)
val agentProcess = agentPlatform.createAgentProcessFrom(
agent = travelAgent,
processOptions = ProcessOptions(),
travelRequest,
userPreferences
)
3.16.2. Using AgentInvocation
AgentInvocation provides a higher-level, type-safe API for invoking agents.
It automatically finds the appropriate agent based on the expected result type.
Basic Usage
- Java
-
// Simple invocation with explicit result type var invocation = AgentInvocation.create(agentPlatform, TravelPlan.class); TravelPlan plan = invocation.invoke(travelRequest); - Kotlin
-
// Type-safe invocation with inferred result type val invocation: AgentInvocation<TravelPlan> = AgentInvocation.create(agentPlatform) val plan = invocation.invoke(travelRequest)
Invocation with Named Inputs
// Invoke with a map of named inputs
Map<String, Object> inputs = Map.of(
"request", travelRequest,
"preferences", userPreferences
);
TravelPlan plan = invocation.invoke(inputs);
Custom Process Options
Configure verbosity, budget, and other execution options:
- Java
-
var invocation = AgentInvocation.builder(agentPlatform) .options(options -> options .verbosity(verbosity -> verbosity .showPrompts(true) .showResponses(true) .debug(true))) .build(TravelPlan.class); TravelPlan plan = invocation.invoke(travelRequest); - Kotlin
-
val processOptions = ProcessOptions( verbosity = Verbosity( showPrompts = true, showResponses = true, debug = true ) ) val invocation: AgentInvocation<TravelPlan> = AgentInvocation.builder(agentPlatform) .options(processOptions) .build() val plan = invocation.invoke(travelRequest)
Asynchronous Invocation
For long-running operations, use async invocation:
CompletableFuture<TravelPlan> future = invocation.invokeAsync(travelRequest);
// Handle result when complete
future.thenAccept(plan -> {
logger.info("Travel plan generated: {}", plan);
});
// Or wait for completion
TravelPlan plan = future.get();
Agent Selection
AgentInvocation automatically finds agents by examining their goals:
-
Searches all registered agents in the platform
-
Finds agents with goals that produce the requested result type
-
Uses the first matching agent found
-
Throws an error if no suitable agent is available
Real-World Web Application Example
Here’s how AgentInvocation is used in the Tripper travel planning application with htmx for asynchronous UI updates:
@Controller
class TripPlanningController(
private val agentPlatform: AgentPlatform
) {
private val activeJobs = ConcurrentHashMap<String, CompletableFuture<TripPlan>>()
@PostMapping("/plan-trip")
fun planTrip(
@ModelAttribute tripRequest: TripRequest,
model: Model
): String {
// Generate unique job ID for tracking
val jobId = UUID.randomUUID().toString()
// Create agent invocation with custom options
val invocation: AgentInvocation<TripPlan> = AgentInvocation.builder(agentPlatform)
.options { options ->
options.verbosity { verbosity ->
verbosity.showPrompts(true)
.showResponses(false)
.debug(false)
}
}
.build()
// Start async agent execution
val future = invocation.invokeAsync(tripRequest)
activeJobs[jobId] = future
// Set up completion handler
future.whenComplete { result, throwable ->
if (throwable != null) {
logger.error("Trip planning failed for job $jobId", throwable)
} else {
logger.info("Trip planning completed for job $jobId")
}
}
model.addAttribute("jobId", jobId)
model.addAttribute("tripRequest", tripRequest)
// Return htmx template that will poll for results
return "trip-planning-progress"
}
@GetMapping("/trip-status/{jobId}")
@ResponseBody
fun getTripStatus(@PathVariable jobId: String): ResponseEntity<Map<String, Any>> {
val future = activeJobs[jobId]
?: return ResponseEntity.notFound().build()
return when {
future.isDone -> {
try {
val tripPlan = future.get()
activeJobs.remove(jobId)
ResponseEntity.ok(mapOf(
"status" to "completed",
"result" to tripPlan,
"redirect" to "/trip-result/$jobId"
))
} catch (e: Exception) {
activeJobs.remove(jobId)
ResponseEntity.ok(mapOf(
"status" to "failed",
"error" to e.message
))
}
}
future.isCancelled -> {
activeJobs.remove(jobId)
ResponseEntity.ok(mapOf("status" to "cancelled"))
}
else -> {
ResponseEntity.ok(mapOf(
"status" to "in_progress",
"message" to "Planning your amazing trip..."
))
}
}
}
@GetMapping("/trip-result/{jobId}")
fun showTripResult(
@PathVariable jobId: String,
model: Model
): String {
// Retrieve completed result from cache or database
val tripPlan = tripResultCache[jobId]
?: return "redirect:/error"
model.addAttribute("tripPlan", tripPlan)
return "trip-result"
}
@DeleteMapping("/cancel-trip/{jobId}")
@ResponseBody
fun cancelTrip(@PathVariable jobId: String): ResponseEntity<Map<String, String>> {
val future = activeJobs[jobId]
return if (future != null && !future.isDone) {
future.cancel(true)
activeJobs.remove(jobId)
ResponseEntity.ok(mapOf("status" to "cancelled"))
} else {
ResponseEntity.badRequest()
.body(mapOf("error" to "Job not found or already completed"))
}
}
companion object {
private val logger = LoggerFactory.getLogger(TripPlanningController::class.java)
private val tripResultCache = ConcurrentHashMap<String, TripPlan>()
}
}
Key Patterns:
-
Async Execution: Uses
invokeAsync()to avoid blocking the web request -
Job Tracking: Maintains a map of active futures for status polling
-
htmx Integration: Returns status updates that htmx can consume for UI updates
-
Error Handling: Proper exception handling and user feedback
-
Resource Cleanup: Removes completed jobs from memory
-
Process Options: Configures verbosity and debugging for production use
| Agents can also be exposed as MCP servers and consumed from tools like Claude Desktop. |
3.17. Using States
GOAP planning has many benefits, but can make looping hard to express. For this reason, Embabel supports the notion of states within a GOAP plan.
3.17.1. How States Work with GOAP
Within each state, GOAP planning works normally. Actions have preconditions based on the types they require, and effects based on the types they produce. The planner finds the optimal sequence of actions to reach the goal.
The key difference is that when an action returns a @State-annotated class, the framework:
-
Clears the blackboard - Only the returned state object remains
-
Re-plans from the new state - The planner considers only actions available in the new state
-
Continues execution - Until a goal is reached or no plan can be found
This blackboard clearing is what enables looping: when you return to a previously visited state type, the hasRun tracking is effectively reset because the blackboard has been cleared.
3.17.2. When to Use States
States are ideal for:
-
Linear stages where each stage naturally flows to the next
-
Branching workflows where a decision point leads to different processing paths
-
Looping patterns where processing may need to repeat (e.g., revise-and-review cycles)
-
Human-in-the-loop workflows where user feedback determines the next state
-
Complex workflows that are easier to reason about as discrete phases
States allow loopback to a whole state, which may contain one or more actions. This is more flexible than traditional GOAP, where looping requires careful management of preconditions.
3.17.3. The @State Annotation
Classes returned from actions that should trigger state transitions must be annotated with @State:
@State
record ProcessingState(String data) {
@Action
NextState process() {
return new NextState(data.toUpperCase());
}
}
Inheritance
The @State annotation is inherited through the class hierarchy.
If a superclass or interface is annotated with @State, all subclasses and implementing classes are automatically considered state types.
This means you don’t need to annotate every class in a hierarchy - just annotate the base type.
@State
interface Stage {} (1)
record AssessStory(String content) implements Stage { ... } (2)
record ReviseStory(String content) implements Stage { ... }
record Done(String content) implements Stage { ... }
| 1 | Only the parent interface needs @State |
| 2 | Implementing records are automatically treated as state types |
This works with:
-
Interfaces: Classes implementing a
@Stateinterface are state types -
Abstract classes: Classes extending a
@Stateabstract class are state types -
Concrete classes: Classes extending a
@Stateclass are state types -
Deep hierarchies: The annotation is inherited through multiple levels
Behavior
When an action returns a @State-annotated class (or a class that inherits @State):
-
The returned object is bound to the blackboard (as
it) -
All other objects on the blackboard are cleared
-
Planning considers actions defined within the state class
-
Any
@AchievesGoalmethods in the state become potential goals
When entering a state, everything else on the blackboard is cleared.
This means you must pass any necessary data to the state instance itself.
For example, in the WriteAndReviewAgent below, each state carries userInput, story, and properties as fields because these won’t be available on the blackboard after the state transition.
|
3.17.4. Parent State Interface Pattern
For dynamic choice between states, define a parent interface (or sealed interface/class) that child states implement. Thanks to inheritance, you only need to annotate the parent interface - all implementing classes are automatically state types:
@State
interface Stage {} (1)
record AssessStory(String content) implements Stage { (2)
@Action
Stage assess() {
if (isAcceptable()) {
return new Done(content);
} else {
return new ReviseStory(content);
}
}
}
record ReviseStory(String content) implements Stage {
@Action
AssessStory revise() {
return new AssessStory(improvedContent());
}
}
record Done(String content) implements Stage {
@AchievesGoal(description = "Processing complete")
@Action
Output complete() {
return new Output(content);
}
}
| 1 | @State on the parent interface |
| 2 | No @State needed on implementing records - they inherit it from Stage |
This pattern enables:
-
Polymorphic return types: Actions can return any implementation of the parent interface
-
Dynamic routing: The runtime value determines which state is entered
-
Looping: States can return other states that eventually loop back
The framework automatically discovers all implementations of the parent interface and registers their actions as potential next steps.
3.17.5. Example: WriteAndReviewAgent
The following example demonstrates a complete write-and-review workflow with:
-
State-based flow control with looping
-
Human-in-the-loop feedback using
WaitFor -
LLM-powered content generation and assessment
-
Configurable properties passed through states
abstract class Personas { (1)
static final RoleGoalBackstory WRITER = RoleGoalBackstory
.withRole("Creative Storyteller")
.andGoal("Write engaging and imaginative stories")
.andBackstory("Has a PhD in French literature; used to work in a circus");
static final Persona REVIEWER = new Persona(
"Media Book Review",
"New York Times Book Reviewer",
"Professional and insightful",
"Help guide readers toward good stories"
);
}
@Agent(description = "Generate a story based on user input and review it")
public class WriteAndReviewAgent {
public record Story(String text) {}
public record ReviewedStory(
Story story,
String review,
Persona reviewer
) implements HasContent, Timestamped {
// ... content formatting methods
}
@State
interface Stage {} (2)
record Properties( (3)
int storyWordCount,
int reviewWordCount
) {}
private final Properties properties;
WriteAndReviewAgent(
@Value("${storyWordCount:100}") int storyWordCount,
@Value("${reviewWordCount:100}") int reviewWordCount
) {
this.properties = new Properties(storyWordCount, reviewWordCount);
}
@Action
AssessStory craftStory(UserInput userInput, Ai ai) { (4)
var draft = ai
.withLlm(LlmOptions.withAutoLlm().withTemperature(.7))
.withPromptContributor(Personas.WRITER)
.createObject(String.format("""
Craft a short story in %d words or less.
The story should be engaging and imaginative.
Use the user's input as inspiration if possible.
# User input
%s
""",
properties.storyWordCount,
userInput.getContent()
).trim(), Story.class);
return new AssessStory(userInput, draft, properties); (5)
}
record HumanFeedback(String comments) {} (6)
private record AssessmentOfHumanFeedback(boolean acceptable) {}
@State
record AssessStory(UserInput userInput, Story story, Properties properties) implements Stage {
@Action
HumanFeedback getFeedback() { (7)
return WaitFor.formSubmission("""
Please provide feedback on the story
%s
""".formatted(story.text),
HumanFeedback.class);
}
@Action
Stage assess(HumanFeedback feedback, Ai ai) { (8)
var assessment = ai.withDefaultLlm().createObject("""
Based on the following human feedback, determine if the story is acceptable.
Return true if the story is acceptable, false otherwise.
# Story
%s
# Human feedback
%s
""".formatted(story.text(), feedback.comments),
AssessmentOfHumanFeedback.class);
if (assessment.acceptable) {
return new Done(userInput, story, properties); (9)
} else {
return new ReviseStory(userInput, story, feedback, properties); (10)
}
}
}
@State
record ReviseStory(UserInput userInput, Story story, HumanFeedback humanFeedback,
Properties properties) implements Stage {
@Action
AssessStory reviseStory(Ai ai) { (11)
var draft = ai
.withLlm(LlmOptions.withAutoLlm().withTemperature(.7))
.withPromptContributor(Personas.WRITER)
.createObject(String.format("""
Revise a short story in %d words or less.
Use the user's input as inspiration if possible.
# User input
%s
# Previous story
%s
# Revision instructions
%s
""",
properties.storyWordCount,
userInput.getContent(),
story.text(),
humanFeedback.comments
).trim(), Story.class);
return new AssessStory(userInput, draft, properties); (12)
}
}
@State
record Done(UserInput userInput, Story story, Properties properties) implements Stage {
@AchievesGoal( (13)
description = "The story has been crafted and reviewed by a book reviewer",
export = @Export(remote = true, name = "writeAndReviewStory"))
@Action
ReviewedStory reviewStory(Ai ai) {
var review = ai
.withAutoLlm()
.withPromptContributor(Personas.REVIEWER)
.generateText(String.format("""
You will be given a short story to review.
Review it in %d words or less.
Consider whether the story is engaging, imaginative, and well-written.
# Story
%s
# User input that inspired the story
%s
""",
properties.reviewWordCount,
story.text(),
userInput.getContent()
).trim());
return new ReviewedStory(story, review, Personas.REVIEWER);
}
}
}
| 1 | Personas: Reusable prompt contributors that give the LLM context about its role |
| 2 | Parent state interface: Allows actions to return any implementing state dynamically |
| 3 | Properties record: Configuration bundled together for easy passing through states |
| 4 | Entry action: Uses LLM to generate initial story draft |
| 5 | State transition: Returns AssessStory with all necessary data; blackboard will be cleared |
| 6 | HITL data type: Simple record to capture human feedback |
| 7 | WaitFor integration: Pauses execution and waits for user to submit feedback form |
| 8 | LLM-powered assessment: Uses AI to interpret human feedback and decide next state |
| 9 | Terminal branch: If acceptable, transitions to Done state |
| 10 | Loop branch: If not acceptable, transitions to ReviseStory with the feedback |
| 11 | Revision action: Uses LLM to revise story based on feedback |
| 12 | Loop back: Returns new AssessStory for another round of feedback |
| 13 | Goal achievement: Final action that produces the reviewed story and exports it |
3.17.6. Execution Flow
The execution flow for this agent:
-
craftStoryexecutes with LLM, returnsAssessStory→ blackboard cleared, entersAssessStorystate -
getFeedbackcallsWaitFor.formSubmission()→ agent pauses, waits for user input -
User submits feedback →
HumanFeedbackadded to blackboard -
assessexecutes with LLM to interpret feedback:-
If acceptable: returns
Done→ entersDonestate -
If not acceptable: returns
ReviseStory→ entersReviseStorystate
-
-
If in
ReviseStory:reviseStoryexecutes with LLM, returnsAssessStory→ loop back to step 2 -
When in
Done:reviewStoryexecutes with LLM, returnsReviewedStory→ goal achieved
The planner handles all transitions automatically, including loops. Each state transition clears the blackboard, so the planner doesn’t see stale objects from previous iterations.
3.17.7. Human-in-the-Loop with WaitFor
The WaitFor.formSubmission() method is key for human-in-the-loop workflows:
@Action
HumanFeedback getFeedback() {
return WaitFor.formSubmission("""
Please provide feedback on the story
%s
""".formatted(story.text),
HumanFeedback.class);
}
When this action executes:
-
The agent process enters a
WAITINGstate -
A form is generated based on the
HumanFeedbackrecord structure -
The user sees the prompt and fills out the form
-
Upon submission, the
HumanFeedbackinstance is created and added to the blackboard -
The agent resumes execution with the feedback available
This integrates naturally with the state pattern: the feedback stays within the current state until the next state transition.
3.17.8. Passing Data Through States
Since the blackboard is cleared on state transitions, all necessary context must be passed through state records:
@State
record AssessStory(
UserInput userInput, // Original user request
Story story, // Current story draft
Properties properties // Configuration
) implements Stage { ... }
@State
record ReviseStory(
UserInput userInput,
Story story,
HumanFeedback humanFeedback, // Additional context for revision
Properties properties
) implements Stage { ... }
Use a Properties record to bundle configuration values that need to pass through multiple states, rather than repeating individual fields.
|
3.17.9. State Class Requirements
State classes should be static nested classes (Java) or top-level classes (Kotlin), not non-static inner classes.
Non-static inner classes hold a reference to their enclosing instance, which causes serialization and persistence issues.
The framework will log a warning if it detects a non-static inner class annotated with @State.
|
// GOOD: Static nested class (Java record is implicitly static)
@State
record AssessStory(UserInput userInput, Story story) implements Stage { ... }
// BAD: Non-static inner class - will cause persistence issues
@State
class AssessStory implements Stage { ... } // Inner class in non-static context
In Java, records declared inside a class are implicitly static, making them ideal for state classes. In Kotlin, data classes declared inside a class are inner by default; use top-level declarations or companion object patterns instead.
3.17.10. Key Points
-
Annotate state classes with
@State(or inherit from a@State-annotated type) -
@Stateis inherited through class hierarchies - annotate only the base type -
Use static nested classes (Java records) or top-level classes to avoid persistence issues
-
Use a parent interface for polymorphic state returns
-
State actions are automatically discovered and registered
-
Blackboard is cleared on state transitions, enabling natural loops
-
Pass all necessary data through state record fields
-
Goals are defined with
@AchievesGoalon terminal state actions -
Use
WaitForfor human-in-the-loop interactions within states -
Within a state, normal GOAP planning applies to sequence actions
3.18. Choosing a Planner
Embabel supports multiple planning strategies. Most are deterministic, but their behaviour differs—although it is always predictable.
All planning strategies are entirely typesafe in Java or Kotlin.
The planning strategies currently supported out of the box are:
| Planner | Best For | Description |
|---|---|---|
GOAP (default) |
Business processes with defined outputs |
Goal-oriented, deterministic planning. Plans a path from current state to goal using preconditions and effects. |
Utility |
Exploration and event-driven systems |
Selects the highest-value available action at each step. Ideal when you don’t know the outcome upfront. |
Supervisor |
Flexible multi-step workflows |
LLM-orchestrated composition. An LLM selects which actions to call based on type schemas and gathered artifacts. |
As most of the documentation covers GOAP, this section discusses the alternative planners and nested workflows.
3.18.1. Utility AI
Utility AI selects the action with the highest net value from all available actions at each step. Unlike GOAP, which plans a path to a goal, Utility AI makes greedy decisions based on immediate value.
Utility AI excels in exploratory scenarios where you don’t know exactly what you want to achieve. Consider a GitHub issue triage system: when a new issue arrives, you don’t have a predetermined goal. Instead, you want to react appropriately based on the issue’s characteristics—maybe label it, maybe respond, maybe escalate. The "right" action depends on what you discover as you process it.
This makes Utility AI ideal for scenarios where:
-
There is no clear end goal—you’re exploring possibilities
-
Multiple actions could be valuable depending on context
-
You want to respond to changing conditions as they emerge
-
The best outcome isn’t known upfront
When to Use Utility AI
-
Event-driven systems: React to incoming events (issues, stars, webhooks) with the most appropriate action
-
Chatbots: Where the platform provides multiple response options and selects the best one
-
Exploration: When you want to discover what’s possible rather than achieve a specific goal
Using Utility AI with @EmbabelComponent
For Utility AI, actions are typically provided via @EmbabelComponent rather than @Agent.
This allows the platform to select actions across multiple components based on utility, rather than constraining actions to a single agent.
Here’s an example from the Shepherd project that reacts to GitHub events:
@EmbabelComponent (1)
class IssueActions(
val properties: ShepherdProperties,
private val communityDataManager: CommunityDataManager,
private val gitHubUpdater: GitHubUpdater,
) {
@Action(outputBinding = "ghIssue") (2)
fun saveNewIssue(ghIssue: GHIssue, context: OperationContext): GHIssue? {
val existing = communityDataManager.findIssueByGithubId(ghIssue.id)
if (existing == null) {
val issueEntityStatus = communityDataManager.saveAndExpandIssue(ghIssue)
context += issueEntityStatus (3)
return ghIssue
}
return null (4)
}
@Action(
pre = ["spel:newEntity.newEntities.?[#this instanceof T(com.embabel.shepherd.domain.Issue)].size() > 0"] (5)
)
fun reactToNewIssue(
ghIssue: GHIssue,
newEntity: NewEntity<*>,
ai: Ai
): IssueAssessment {
return ai
.withLlm(properties.triageLlm)
.creating(IssueAssessment::class.java)
.fromTemplate("first_issue_response", mapOf("issue" to ghIssue)) (6)
}
@Action(pre = ["spel:issueAssessment.urgency > 0.0"]) (7)
fun heavyHitterIssue(issue: GHIssue, issueAssessment: IssueAssessment) {
// Take action on high-urgency issues
}
}
| 1 | @EmbabelComponent contributes actions to the platform, not a specific agent |
| 2 | outputBinding names the result for later actions to reference |
| 3 | Add entity status to context, making it available to subsequent actions |
| 4 | Returning null prevents further actions from firing for this issue |
| 5 | SpEL precondition: only fire if new issues were created |
| 6 | Use AI to assess the issue via a template |
| 7 | This action only fires if the assessment shows urgency > 0 |
The platform selects which action to run based on:
-
Which preconditions are satisfied (type availability + SpEL conditions)
-
The
costandvalueparameters on@Action(net value = value - cost)
Action Cost and Value
The @Action annotation supports cost and value parameters (both 0.0 to 1.0):
@Action(
cost = 0.1, (1)
value = 0.8 (2)
)
fun highValueAction(input: Input): Output {
// Action implementation
}
| 1 | Cost to execute (0.0 to 1.0) - lower is cheaper |
| 2 | Value when executed (0.0 to 1.0) - higher is more valuable |
The Utility planner calculates net value as value - cost and selects the action with the highest net value from all available actions.
The Nirvana Goal
Utility AI supports a special "Nirvana" goal that is never satisfied. This keeps the process running, continuously selecting the highest-value available action until no actions are available.
Extensibility
Utility AI fosters extensibility.
For example, multiple groups within an organization can contribute their own @EmbabelComponent classes with actions that bring their own expertise to enhance behaviours around shared types, while retaining the ability to own and control their own extended model.
Utility and States
Utility AI can combine with the @State annotation to implement classification and routing patterns.
This is particularly useful when you need to:
-
Classify input into different categories at runtime
-
Route processing through category-specific handlers
-
Achieve different goals based on classification
The key pattern is:
-
An entry action classifies input and returns a
@Statetype -
Each
@Stateclass contains an@AchievesGoalaction that produces the final output -
The
@AchievesGoaloutput is not a@Statetype (to prevent infinite loops)
Here’s an example of a ticket triage system that routes support tickets based on severity:
@Agent(
description = "Triage and process support tickets",
planner = PlannerType.UTILITY (1)
)
class TicketTriageAgent {
data class Ticket(val id: String, val description: String, val customerId: String)
data class ResolvedTicket(val id: String, val resolution: String, val handledBy: String)
@State
sealed interface TicketCategory (2)
@Action
fun triageTicket(ticket: Ticket): TicketCategory { (3)
return when {
ticket.description.contains("down", ignoreCase = true) ->
CriticalTicket(ticket)
ticket.description.contains("bug", ignoreCase = true) ->
BugTicket(ticket)
else ->
GeneralTicket(ticket)
}
}
@State
data class CriticalTicket(val ticket: Ticket) : TicketCategory {
@AchievesGoal(description = "Handle critical ticket with immediate escalation") (4)
@Action
fun handleCritical(): ResolvedTicket {
return ResolvedTicket(
id = ticket.id,
resolution = "Escalated to on-call engineer",
handledBy = "CRITICAL_RESPONSE_TEAM"
)
}
}
@State
data class BugTicket(val ticket: Ticket) : TicketCategory {
@AchievesGoal(description = "Handle bug report")
@Action
fun handleBug(): ResolvedTicket {
return ResolvedTicket(
id = ticket.id,
resolution = "Bug logged in issue tracker",
handledBy = "ENGINEERING_TEAM"
)
}
}
@State
data class GeneralTicket(val ticket: Ticket) : TicketCategory {
@AchievesGoal(description = "Handle general inquiry")
@Action
fun handleGeneral(): ResolvedTicket {
return ResolvedTicket(
id = ticket.id,
resolution = "Response sent with FAQ links",
handledBy = "SUPPORT_TEAM"
)
}
}
}
| 1 | Use PlannerType.UTILITY for opportunistic action selection |
| 2 | Sealed interface as the state supertype |
| 3 | Entry action classifies and returns a @State instance |
| 4 | Each state has an @AchievesGoal action producing the final output |
When a Ticket is processed:
-
The
triageTicketaction classifies it into one of the state types -
Entering a state clears other objects from the blackboard
-
The Utility planner selects the
@AchievesGoalaction for that state -
The goal is achieved when
ResolvedTicketis produced
This pattern works well when:
-
Classification determines the processing path
-
Each category has distinct handling requirements
-
The final output type is the same across all categories
3.18.2. Supervisor
The Supervisor planner uses an LLM to orchestrate actions dynamically. This is a popular pattern in frameworks like LangGraph and Google ADK, where a supervisor LLM decides which tools to call and in what order.
|
Unlike GOAP and Utility, the Supervisor planner is non-deterministic. The LLM may choose different action sequences for the same inputs. This makes it less suitable for business-critical workflows requiring reproducibility. |
Type-Informed vs Type-Driven
A key design decision in supervisor architectures is how types relate to composition:
| Approach | Description |
|---|---|
Type-Driven (GOAP) |
Types constrain composition. An action requiring |
Type-Informed (Supervisor) |
Types inform composition. The LLM sees type schemas and decides what to call based on semantic understanding. This is flexible but non-deterministic. |
Embabel’s Supervisor planner takes the type-informed approach while maximizing the benefits of types:
-
Actions return typed outputs that are validated
-
The LLM sees type schemas to understand what each action produces
-
Results are stored on the typed blackboard for later actions
-
The same actions work with any planner (GOAP, Utility, or Supervisor)
This is a "typed supervisor" pattern—a middle ground between fully type-driven (GOAP) and untyped string-passing (typical LangGraph).
When to Use Supervisor
Supervisor is appropriate when:
-
Action ordering is context-dependent and hard to predefine
-
You want an LLM to synthesize information across multiple sources
-
The workflow benefits from flexible composition rather than strict sequencing
-
Non-determinism is acceptable for your use case
Supervisor is not recommended when:
-
You need reproducible, auditable execution paths
-
Actions have strict dependency ordering that must be enforced
-
Latency and cost matter (each decision requires an LLM call)
Using Supervisor
To use Supervisor, annotate your agent with planner = PlannerType.SUPERVISOR and mark one action with @AchievesGoal:
@Agent(
planner = PlannerType.SUPERVISOR,
description = "Market research report generator"
)
class MarketResearchAgent {
data class MarketDataRequest(val topic: String)
data class MarketData(val revenues: Map<String, String>, val marketShare: Map<String, Double>)
data class CompetitorAnalysisRequest(val companies: List<String>)
data class CompetitorAnalysis(val strengths: Map<String, List<String>>)
data class ReportRequest(val topic: String, val companies: List<String>)
data class FinalReport(val title: String, val sections: List<String>)
@Action(description = "Gather market data including revenues and market share") (1)
fun gatherMarketData(request: MarketDataRequest, ai: Ai): MarketData {
return ai.withDefaultLlm().createObject(
"Generate market data for: ${request.topic}"
)
}
@Action(description = "Analyze competitors: strengths and positioning")
fun analyzeCompetitors(request: CompetitorAnalysisRequest, ai: Ai): CompetitorAnalysis {
return ai.withDefaultLlm().createObject(
"Analyze competitors: ${request.companies.joinToString()}"
)
}
@AchievesGoal(description = "Compile all information into a final report") (2)
@Action(description = "Compile the final report")
fun compileReport(request: ReportRequest, ai: Ai): FinalReport {
return ai.withDefaultLlm().createObject(
"Create a market research report for ${request.topic}"
)
}
}
| 1 | Tool actions have descriptions visible to the supervisor LLM |
| 2 | The goal action is called when the supervisor has gathered enough information |
The supervisor LLM sees type schemas for available actions:
Available actions:
- gatherMarketData(request: MarketDataRequest) -> MarketData
Schema: { revenues: Map, marketShare: Map }
- analyzeCompetitors(request: CompetitorAnalysisRequest) -> CompetitorAnalysis
Schema: { strengths: Map }
Current artifacts on blackboard:
- MarketData: { revenues: {"CompanyA": "$10B"}, marketShare: {...} }
Goal: FinalReport
The LLM decides action ordering based on this information, making informed decisions without being constrained by declared dependencies.
Interoperability
Using wrapper request types (like MarketDataRequest) enables actions to work with any planner:
-
GOAP: Request types flow through the blackboard based on preconditions/effects
-
Utility: Actions fire when their request types are available with highest net value
-
Supervisor: The LLM constructs request objects to call actions
This means you can switch planners without changing your action code—useful for testing with deterministic planners (GOAP) and deploying with flexible planners (Supervisor).
Comparison with LangGraph
LangGraph’s supervisor pattern is a popular approach for multi-agent orchestration. Here’s how a similar workflow looks in LangGraph vs Embabel:
from langgraph_supervisor import create_supervisor
from langgraph.prebuilt import create_react_agent
# Tools return strings - no type information
def gather_market_data(topic: str) -> str:
"""Gather market data for a topic."""
return f"Revenue data for {topic}..." (1)
def analyze_competitors(companies: str) -> str:
"""Analyze competitors."""
return f"Analysis of {companies}..." (1)
# Create agents with tools
research_agent = create_react_agent(
model="openai:gpt-4o",
tools=[gather_market_data, analyze_competitors],
name="research_expert",
)
# Supervisor sees all tools, always (2)
workflow = create_supervisor([research_agent], model=model)
app = workflow.compile()
# State is a dict of messages (3)
result = app.invoke({"messages": [{"role": "user", "content": "Research cloud market"}]})
| 1 | Tools return strings—the LLM must parse and interpret results |
| 2 | All tools always visible—no filtering based on context |
| 3 | State is untyped message history |
@Agent(planner = PlannerType.SUPERVISOR)
class MarketResearchAgent {
// Tools return typed objects with schemas (1)
@Action(description = "Gather market data for a topic")
fun gatherMarketData(request: MarketDataRequest, ai: Ai): MarketData {
return ai.withDefaultLlm().createObject("Generate market data for ${request.topic}")
}
@Action(description = "Analyze competitors")
fun analyzeCompetitors(request: CompetitorAnalysisRequest, ai: Ai): CompetitorAnalysis {
return ai.withDefaultLlm().createObject("Analyze ${request.companies}")
}
@AchievesGoal
@Action
fun compileReport(request: ReportRequest, ai: Ai): FinalReport { ... }
}
// State is a typed blackboard (2)
// Tools are filtered based on available inputs (3)
| 1 | Tools return typed, validated objects--MarketData, CompetitorAnalysis |
| 2 | Blackboard holds typed artifacts, not just message strings |
| 3 | Tools with satisfied inputs are prioritized via currying |
Key Advantages
Embabel’s Supervisor offers several advantages over typical supervisor implementations:
| Aspect | Typical Supervisor (LangGraph) | Embabel Supervisor |
|---|---|---|
Output Types |
Strings—LLM must parse |
Typed objects—validated and structured |
Tool Visibility |
All tools always available |
Tools filtered by blackboard state (currying) |
Domain Awareness |
None—tools are opaque functions |
Type schemas visible to LLM |
Determinism |
Fully non-deterministic |
Semi-deterministic: tool availability constrained by types |
State |
Untyped message history |
Typed blackboard with named artifacts |
Blackboard-Driven Tool Filtering
A key differentiator is curried tool filtering. When an action’s inputs are already on the blackboard, those parameters are "curried out"--the tool signature simplifies.
|
What is Currying?
Currying is a functional programming technique where a function with multiple parameters is transformed into a sequence of functions, each taking a single parameter. In Embabel’s context: if an action requires |
# Initial state: empty blackboard
Available tools:
- gatherMarketData(request: MarketDataRequest) -> MarketData
- analyzeCompetitors(request: CompetitorAnalysisRequest) -> CompetitorAnalysis
# After MarketData is gathered:
Available tools:
- gatherMarketData(request: MarketDataRequest) -> MarketData [READY - 0 params needed]
- analyzeCompetitors(request: CompetitorAnalysisRequest) -> CompetitorAnalysis
This reduces the LLM’s decision space and guides it toward logical next steps—tools with satisfied inputs appear "ready" with fewer parameters. This is more deterministic than showing all tools equally, while remaining more flexible than GOAP’s strict ordering.
Semi-Determinism
While still LLM-orchestrated, Embabel’s Supervisor is more deterministic than typical implementations:
-
Type constraints: Actions can only produce specific types—no arbitrary string outputs
-
Input filtering: Tools unavailable until their input types exist
-
Schema guidance: LLM sees what each action produces, not just descriptions
-
Validated outputs: Results must conform to declared types
This makes debugging easier and behaviour more predictable, while retaining the flexibility that makes supervisor patterns valuable.
When Embabel’s Approach Excels
-
Domain-rich workflows: When your domain has clear types (reports, analyses, forecasts), schemas help the LLM understand relationships
-
Multi-step synthesis: When actions build on each other’s outputs, typed blackboard tracks progress clearly
-
Hybrid determinism: When you want more predictability than pure LLM orchestration but more flexibility than GOAP
SupervisorInvocation: Lightweight Supervisor Pattern
For simple supervisor workflows, you don’t need to create an @Agent class.
SupervisorInvocation provides a fluent API to run supervisor-orchestrated workflows directly from @EmbabelComponent actions.
This is ideal when:
-
You have a small set of related actions in an
@EmbabelComponent -
You want LLM-orchestrated composition without creating a full agent
-
You’re prototyping or exploring supervisor patterns before committing to a full agent design
Example: Meal Preparation Workflow
Here’s a complete example from the embabel-agent-examples repository:
@EmbabelComponent
public class Stages {
public record Cook(String name, int age) {}
public record Order(String dish, int quantity) {}
public record Meal(String dish, int quantity, String orderedBy, String cookedBy) {}
@Action
public Cook chooseCook(UserInput userInput, Ai ai) {
return ai.withAutoLlm().createObject(
"""
From the following user input, choose a cook.
User input: %s
""".formatted(userInput),
Cook.class
);
}
@Action
public Order takeOrder(UserInput userInput, Ai ai) {
return ai.withAutoLlm().createObject(
"""
From the following user input, take a food order
User input: %s
""".formatted(userInput),
Order.class
);
}
@Action
@AchievesGoal(description = "Cook the meal according to the order")
public Meal prepareMeal(Cook cook, Order order, UserInput userInput, Ai ai) {
// The model will get the orderedBy from UserInput
return ai.withAutoLlm().createObject(
"""
Prepare a meal based on the cook and order details and target customer
Cook: %s, age %d
Order: %d x %s
User input: %s
""".formatted(cook.name(), cook.age(), order.quantity(), order.dish(), userInput.getContent()),
Meal.class
);
}
}
Stages stages = new Stages();
Meal meal = SupervisorInvocation.on(agentPlatform)
.returning(Stages.Meal.class)
.withScope(AgentScopeBuilder.fromInstance(stages))
.invoke(new UserInput(request));
The supervisor LLM sees:
-
Available actions with their type signatures and schemas
-
Current artifacts on the blackboard (including
UserInputcontent) -
Goal to produce a
Meal
It then orchestrates the actions—calling chooseCook and takeOrder (possibly in parallel), then prepareMeal when the dependencies are satisfied.
Key Design Points
-
Actions use UserInput explicitly: Each action receives
UserInputand includes it in the LLM prompt, ensuring the actual user request is used. -
@AchievesGoal marks the target: The
prepareMealaction is marked with@AchievesGoalto indicate it produces the final output. -
Type-driven dependencies:
prepareMealrequiresCookandOrder, which guides the supervisor’s orchestration.
SupervisorInvocation vs @Agent with planner = SUPERVISOR
| Aspect | SupervisorInvocation | @Agent(planner = SUPERVISOR) |
|---|---|---|
Declaration |
Fluent API, no class annotation |
Annotated agent class |
Action source |
|
Single |
Best for |
Quick prototypes, simple workflows |
Formalized, reusable agents |
Goal specification |
|
|
Scope |
Explicit via |
Implicit from agent class |
Comparison with AgenticTool
Both SupervisorInvocation and AgenticTool provide LLM-orchestrated composition, but at different levels:
| Aspect | AgenticTool | SupervisorInvocation |
|---|---|---|
Level |
Tool (can be used within actions) |
Invocation (runs a complete workflow) |
Sub-components |
Other |
|
Output |
|
Typed goal object (e.g., |
State management |
Minimal (LLM conversation only) |
Full blackboard with typed artifacts |
Type awareness |
Tools have names and descriptions |
Actions have typed inputs/outputs with schemas |
Currying |
None |
Inputs on blackboard are curried out |
Use case |
Mini-orchestration within an action |
Complete multi-step workflow with typed results |
Use AgenticTool when you need a tool that internally orchestrates other tools.
Use SupervisorInvocation when you need a complete workflow that produces a typed result with full blackboard state management.
3.19. API vs SPI
Embabel makes a clean distinction between its API and SPI. The API is the public interface that users interact with, while the SPI (Service Provider Interface) is intended for developers who want to extend or customize the behavior of Embabel, or platform providers.
| Application code should only depend on the API (com.embabel.agent.api.*) not the SPI. The SPI is subject to change and should not be used in production code. |
3.20. Embabel and Spring
Embabel embraces Spring. Spring was revolutionary when it arrived, and two decades on it still defines how most JVM applications are built. You may already know Spring from years of Java or Kotlin development. Or perhaps you’re arriving from Python or another ecosystem. In any case it’s worth noting that Embabel was spearheaded by the creator of Spring himself: the noteworthy Rod Johnson.
Embabel has been assembled using the Spring core platform and then builds upon the Spring AI portfolio project.
We recommend using Spring Boot for building Embabel applications. Not only does it provide a familiar environment for JVM developers, its philosophy is highly relevant for anyone aiming to craft a production-grade agentic AI application.
Why? Because the foundation of the Spring framework is:
-
Composability via discreet, fit-for-purpose reusable units. Dependency injection facilitates this.
-
Cross-cutting abstractions — such as transaction management and security. Aspect-oriented programming (AOP) is what makes this work.
This same foundation makes it possible to craft agentic applications that are composable, testable, and built on enterprise-grade service abstractions. With ~70% of production applications deployed on the JVM, Embabel can bring AI super-powers to existing systems — extending their value rather than replacing them. In this way, Embabel applies the Spring philosophy so that agentic applications are not just clever demos, but truly production-ready systems.
3.21. Working with LLMs
Embabel supports any LLM supported by Spring AI. In practice, this is just about any LLM.
3.21.1. Choosing an LLM
Embabel encourages you to think about LLM choice for every LLM invocation.
The PromptRunner interface makes this easy.
Because Embabel enables you to break agentic flows up into multiple action steps, each step can use a smaller, focused prompt with fewer tools.
This means it may be able to use a smaller LLM.
Considerations:
-
Consider the complexity of the return type you expect from the LLM. This is typically a good proxy for determining required LLM quality. A small LLM is likely to struggle with a deeply nested return structure.
-
Consider the nature of the task. LLMs have different strengths; review any available documentation. You don’t necessarily need a huge, expensive model that is good at nearly everything, at the cost of your wallet and the environment.
-
Consider the sophistication of tool calling required. Simple tool calls are fine, but complex orchestration is another indicator you’ll need a strong LLM. (It may also be an indication that you should create a more sophisticated flow using Embabel GOAP.)
-
Consider trying a local LLM running under Ollama or Docker.
| Trial and error is your friend. Embabel makes it easy to switch LLMs; try the cheapest thing that could work and switch if it doesn’t. |
3.22. Working with Streams
Developer can choose piping data from LLM gradually, using Embabel streaming capabilities.
Streams include LLM reasoning events, so-called "thinking", and stream of objects. This feature is well aligned with Embabel focus on object-oriented programming model
3.22.1. Concepts
-
StreamingEvent- wraps Thinking or user Object -
- runner with streaming capabilitiesStreamingPromptRunnerBuilder -
Spring Reactive Programming Support for Spring AI ChatClient as underlying infrastructure
-
All reactive callbacks, such as doOnNext, doOnComplete, etc. are at developer’s disposal
3.22.2. Example - Simple Streaming with Callbacks
PromptRunner runner = ai.withLlm("qwen3:latest")
.withToolObject(Tooling.class);
String prompt = "What are exactly two the most hottest months in Florida and their respective highest temperatures";
// Use StreamingPromptBuilder instead of Kotlin extension function
Flux<StreamingEvent<MonthItem>> results = new StreamingPromptRunnerBuilder(runner)
.withStreaming()
.withPrompt(prompt)
.createObjectStreamWithThinking(MonthItem.class);
// Subscribe with real reactive callbacks using builder pattern
results
.timeout(Duration.ofSeconds(150))
.doOnSubscribe(subscription -> {
logger.info("Stream subscription started");
})
.doOnNext(event -> {
if (event.isThinking()) {
String content = event.getThinking();
receivedEvents.add("THINKING: " + content);
logger.info("Integration test received thinking: {}", content);
} else if (event.isObject()) {
MonthItem obj = event.getObject();
receivedEvents.add("OBJECT: " + obj.getName());
logger.info("Integration test received object: {}", obj.getName());
}
})
.doOnError(error -> {
errorOccurred.set(error);
logger.error("Integration test stream error: {}", error.getMessage());
})
.doOnComplete(() -> {
completionCalled.set(true);
logger.info("Integration test stream completed successfully");
})
.blockLast(Duration.ofSeconds(6000));
3.23. Customizing Embabel
3.23.1. Adding LLMs
You can add custom LLMs as Spring beans of type Llm.
Llm instances are created around Spring AI ChatModel instances.
You can use any Spring AI compatible model, adding additional Embabel requirements:
-
name (required)
-
provider, such as "Mistral" (required)
-
OptionsConverterto convert EmbabelLlmOptionsto Spring AIChatOptions -
knowledge cutoff date (if available)
-
any additional
PromptContributorobjects to be used in all LLM calls. If knowledge cutoff date is provided, add theKnowledgeCutoffDateprompt contributor. -
pricing model (if available)
For example:
@Configuration
public class LlmsConfig {
@Bean
public Llm myLlm() {
org.springframework.ai.chat.model.ChatModel chatModel = ...
return new Llm(
"myChatModel",
"myChatModelProvider",
chatModel)
.withOptionsConverter(new MyLlmOptionsConverter()) (1)
.withKnowledgeCutoffDate(LocalDate.of(2025, 4, 1)); (2)
}
}
| 1 | Customize the Llm instance with an OptionsConverter implementation to convert Embabel LlmOptions to Spring AI ChatOptions.
This is recommended. |
| 2 | Set the knowledge cutoff date if available.
This will automatically expose it to prompts via the KnowledgeCutoffDate prompt contributor. |
A common requirement is to add an open AI compatible LLM.
This can be done by extending the OpenAiCompatibleModelFactory class as follows:
@Configuration
class CustomOpenAiCompatibleModels(
@Value("\${MY_BASE_URL:#{null}}")
baseUrl: String?,
@Value("\${MY_API_KEY}")
apiKey: String,
observationRegistry: ObservationRegistry,
) : OpenAiCompatibleModelFactory(baseUrl = baseUrl, apiKey = apiKey, observationRegistry = observationRegistry) {
@Bean
fun myGreatModel(): Llm {
// Call superclass method
return openAiCompatibleLlm(
model = "my-great-model",
provider = "me",
knowledgeCutoffDate = LocalDate.of(2025, 1, 1),
pricingModel = PerTokenPricingModel(
usdPer1mInputTokens = .40,
usdPer1mOutputTokens = 1.6,
)
)
}
}
3.23.2. Adding embedding models
Embedding models can also be added as beans of the Embabel type EmbeddingService.
Typically, this is done in an @Configuration class like this:
@Configuration
public class EmbeddingModelsConfig {
@Bean
public EmbeddingService myEmbeddingModel() {
org.springframework.ai.embedding.EmbeddingModel embeddingModel = ...
return new EmbeddingService(
"myEmbeddingModel",
"myEmbeddingModelProvider",
embeddingModel);
}
}
3.23.3. Configuration via application.properties or application.yml
You can specify Spring configuration, your own configuration and Embabel configuration in the regular Spring configuration files. Profile usage will work as expected.
3.23.4. Customizing logging
You can customize logging as in any Spring application.
For example, in application.properties you can set properties like:
logging.level.com.embabel.agent.a2a=DEBUG
You can also configure logging via a logback-spring.xml file if you have more sophisticated requirements.
See the Spring Boot Logging reference.
By default, many Embabel examples use personality-based logging experiences such as Star Wars. You can disable this by updating application.properties accordingly.
embabel.agent.logging.personality=severance
Remove the embabel.agent.logging.personality key to disable personality-based logging.
As all logging results from listening to events via an AgenticEventListener, you can also easily create your own customized logging.
3.24. Integrations
3.24.1. Model Context Protocol (MCP)
Publishing
Overview
Embabel Agent can expose your agents as MCP servers, making them available to external MCP clients such as Claude Desktop, VS Code extensions, or other MCP-compatible applications. The framework provides automatic publishing of agent goals as tools and prompts without requiring manual configuration.
Server Configuration
Configure MCP server functionality in your application.yml.
The server type determines the execution mode:
spring:
ai:
mcp:
server:
type: SYNC # or ASYNC
Server Types
Embabel Agent supports two MCP server execution modes controlled by the spring.ai.mcp.server.type property:
- SYNC Mode (Default)
-
-
Blocking operations wrapped in reactive streams
-
Simpler to develop and debug
-
Suitable for most use cases
-
Better error handling and logging
-
spring:
ai:
mcp:
server:
type: SYNC
- ASYNC Mode
-
-
True non-blocking reactive operations
-
Higher throughput for concurrent requests
-
More complex error handling
-
Suitable for high-performance scenarios
-
spring:
ai:
mcp:
server:
type: ASYNC
Transport Protocol
Embabel Agent uses SSE (Server-Sent Events) transport, exposing your MCP server at localhost:8080/sse.
This is compatible with Claude Desktop, MCP Inspector, Cursor, and most desktop MCP clients.
- Clients requiring Streamable HTTP
-
Some clients (e.g., OpenWebUI) require Streamable HTTP transport instead of SSE. Use the
mcpoproxy to bridge your SSE server:
uvx mcpo --port 8000 --server-type sse -- http://localhost:8080/sse
Then connect your client to localhost:8000.
Automatic Publishing
- Tools
-
Agent goals are automatically published as MCP tools when annotated with
@Export(remote = true). ThePerGoalMcpToolExportCallbackPublisherautomatically discovers and exposes these goals without any additional configuration. - Prompts
-
Prompts are automatically generated for each goal’s starting input types through the
PerGoalStartingInputTypesPromptPublisher. This provides ready-to-use prompt templates based on your agent definitions.
Exposing Agent Goals as Tools
Agent goals become MCP tools automatically when annotated with @Export:
@Agent(
goal = "Provide weather information",
backstory = "Weather service agent"
)
public class WeatherAgent {
@Goal
@Export(remote = true) // Automatically becomes MCP tool
public String getWeather(
@Param("location") String location,
@Param("units") String units
) {
return "Weather for " + location + " in " + units;
}
@Goal
public String internalMethod() {
// Not exposed to MCP (no @Export annotation)
return "Internal use only";
}
}
Exposing Embabel ToolObject and LlmReference types as tools
A common requirement is to expose existing Embabel functionality via MCP.
For example, an LlmReference might be added to a PromptRunner but might also be used as an external tool via MCP.
To do this, use McpToolExport to create a bean of type McpToolExportCallbackPublisher.
For example, to expose a ToolishRag LLM reference as an MCP tool, define a Spring configuration class as follows:
@Configuration
public class RagMcpTools {
@Bean
McpToolExport ragTools( (1)
SearchOperations searchOperations) {
var toolishRag = new ToolishRag(
"docs",
"Embabel docs",
searchOperations
);
return McpToolExport.fromLlmReference(toolishRag); (2)
}
}
| 1 | Your bean should be of type McpToolExport |
| 2 | Use McpToolExport.fromLlmReference to return the instance |
Naming Strategies
When exporting tools, you can control how tool names are transformed using a naming strategy. This is useful for namespacing tools when exporting from multiple sources to avoid naming conflicts.
Using ToolObject with a naming strategy:
@Bean
fun prefixedTools(): McpToolExport {
return McpToolExport.fromToolObject(
ToolObject(
objects = listOf(myToolInstance),
namingStrategy = { "myservice_$it" } (1)
)
)
}
| 1 | All tool names will be prefixed with myservice_ |
Common naming strategies include:
-
Prefix:
{ "namespace_$it" }- adds a prefix to avoid conflicts -
Uppercase:
{ it.uppercase() }- converts to uppercase -
Identity:
StringTransformer.IDENTITY- preserves original names (default)
LlmReference naming:
When using fromLlmReference, the reference’s built-in naming strategy is applied automatically.
This prefixes tool names with the lowercased, normalized reference name.
For example, an LlmReference named "MyAPI" will prefix all tools with myapi_.
// Reference named "WeatherService" will prefix tools with "weatherservice_"
val reference = MyWeatherReference() // name = "WeatherService"
McpToolExport.fromLlmReference(reference)
// Tool "getWeather" becomes "weatherservice_getWeather"
Exporting multiple sources with different prefixes:
@Bean
fun multiSourceTools(): McpToolExport {
return McpToolExport.fromToolObjects(
listOf(
ToolObject(
objects = listOf(weatherTools),
namingStrategy = { "weather_$it" }
),
ToolObject(
objects = listOf(stockTools),
namingStrategy = { "stocks_$it" }
)
)
)
}
Filtering Tools
You can filter which tools are exported using the filter property on ToolObject:
@Bean
fun filteredTools(): McpToolExport {
return McpToolExport.fromToolObject(
ToolObject(
objects = listOf(myToolInstance),
filter = { it.startsWith("public_") } (1)
)
)
}
| 1 | Only tools whose names start with public_ will be exported |
You can combine naming strategies and filters:
@Bean
fun combinedTools(): McpToolExport {
return McpToolExport.fromToolObject(
ToolObject(
objects = listOf(myToolInstance),
namingStrategy = { "api_$it" },
filter = { !it.startsWith("internal") } (1)
)
)
}
| 1 | The filter is applied to the original tool name before the naming strategy transforms it |
Exposing Tools on Spring Components in Spring AI style
It is also possible to expose tools on Spring components as with regular Spring AI.
For example:
@Component
public class CalculatorTools {
@McpTool(name = "add", description = "Add two numbers together")
public int add(
@McpToolParam(description = "First number", required = true) int a,
@McpToolParam(description = "Second number", required = true) int b) {
return a + b;
}
@McpTool(name = "multiply", description = "Multiply two numbers")
public double multiply(
@McpToolParam(description = "First number", required = true) double x,
@McpToolParam(description = "Second number", required = true) double y) {
return x * y;
}
}
Of course, you can inject the Embabel Ai interface to help do the work of the tools if you wish, or invoke other agents from within the tool methods.
For further information, see the Spring AI MCP Annotations Reference.
Server Architecture
The MCP server implementation uses several design patterns:
- Template Method Pattern
-
-
AbstractMcpServerConfigurationprovides common initialization logic -
Concrete implementations (
McpSyncServerConfiguration,McpAsyncServerConfiguration) handle mode-specific details
-
- Strategy Pattern
-
-
Server strategies abstract sync vs async operations
-
Mode-specific implementations handle tool, resource, and prompt management
-
- Publisher Pattern
-
-
Tools, resources, and prompts are discovered through publisher interfaces
-
Automatic registration and lifecycle management
-
Event-driven initialization ensures proper timing
-
Built-in Tools
Every MCP server includes a built-in helloBanner tool that displays server information:
{
"type": "banner",
"mode": "SYNC",
"lines": [
"~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~",
"Embabel Agent MCP SYNC Server",
"Version: 0.3.0-SNAPSHOT",
"Java: 21.0.2+13-LTS-58",
"Started: 2025-01-17T14:23:47.785Z",
"~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"
]
}
Consuming
Embabel Agent can consume external MCP servers as tool sources, automatically organizing them into Tool Groups that agents can use.
Configuration Approaches
- Docker MCP Gateway (Recommended)
-
Uses Docker Desktop’s MCP Toolkit extension as a single gateway to multiple tools:
spring:
ai:
mcp:
client:
type: SYNC
stdio:
connections:
docker-mcp:
command: docker
args: [mcp, gateway, run]
- Individual Containers
-
Run each MCP server as a separate Docker container:
spring:
ai:
mcp:
client:
type: SYNC
stdio:
connections:
brave-search-mcp:
command: docker
args: [run, -i, --rm, -e, BRAVE_API_KEY, mcp/brave-search]
env:
BRAVE_API_KEY: ${BRAVE_API_KEY}
Available Tool Groups
Tool Groups are conditionally created based on configured MCP connections using @ConditionalOnMcpConnection:
| Tool Group | Required Connections | Capabilities |
|---|---|---|
Web Tools |
|
Web search, URL fetching, Wikipedia queries |
Maps |
|
Geocoding, directions, place search |
Browser Automation |
|
Page navigation, screenshots, form interaction |
GitHub |
|
Issues, pull requests, comments |
How It Works
The @ConditionalOnMcpConnection annotation checks for configured connections at startup:
@Bean
@ConditionalOnMcpConnection("github-mcp", "docker-mcp") (1)
fun githubToolsGroup(): ToolGroup {
return McpToolGroup(
description = CoreToolGroups.GITHUB_DESCRIPTION,
name = "docker-github",
clients = mcpSyncClients,
filter = { it.toolDefinition.name().contains("create_issue") } (2)
)
}
| 1 | Bean created if any listed connection is configured |
| 2 | Filter selects which MCP tools belong to this group |
3.25. Agent Skills
Agent Skills provide a standardized way to extend agent capabilities with reusable, shareable skill packages. Skills are loaded dynamically and provide instructions, resources, and tools to agents.
Embabel implements the Agent Skills Specification.
3.25.1. What are Agent Skills?
An Agent Skill is a directory containing a SKILL.md file with YAML frontmatter and markdown instructions.
Skills can also include bundled resources:
-
scripts/- Executable scripts (Python, Bash, etc.) -
references/- Documentation and reference materials -
assets/- Static resources like templates and data files
Skills use a lazy loading pattern: only minimal metadata is included in the system prompt, with full instructions loaded when the skill is activated.
3.25.2. Using Skills with PromptRunner
The Skills class implements LlmReference, allowing it to be passed to a PromptRunner:
var skills = new Skills("financial-skills", "Financial analysis skills")
.withGitHubUrl("https://github.com/wshobson/agents/tree/main/plugins/business-analytics/skills");
var response = context.ai()
.withLlm(llm)
.withReference(skills)
.withSystemPrompt("You are a helpful financial analyst.")
.respond(conversation.getMessages());
When skills are added as a reference, the agent can:
-
See available skills in the system prompt
-
Activate skills to get full instructions
-
List and read skill resources
3.25.3. Loading Skills from GitHub
The simplest way to load skills is from a GitHub URL:
val skills = Skills("my-skills", "Skills for my agent")
.withGitHubUrl("https://github.com/anthropics/skills/tree/main/skills")
Supported URL formats:
-
github.com/owner/repo- Load from repository root -
github.com/owner/repo/tree/branch- Specific branch -
github.com/owner/repo/tree/branch/path/to/skills- Specific path
For more control, use explicit parameters:
val skills = Skills("my-skills", "Skills for my agent")
.withGitHubSkills(
owner = "anthropics",
repo = "skills",
skillsPath = "skills",
branch = "main"
)
3.25.4. Loading Skills from Local Directories
Load a single skill from a directory containing SKILL.md:
val skills = Skills("my-skills", "Local skills")
.withLocalSkill("/path/to/my-skill")
Load multiple skills from a parent directory:
val skills = Skills("my-skills", "Local skills")
.withLocalSkills("/path/to/skills-directory")
withLocalSkills scans immediate subdirectories only (depth 1).
It does not recurse into nested directories.
|
3.25.5. Skill Directory Structure
A skill directory must contain a SKILL.md file:
my-skill/ ├── SKILL.md # Required - metadata and instructions ├── scripts/ # Optional - executable scripts ├── references/ # Optional - documentation └── assets/ # Optional - static resources
The SKILL.md file uses YAML frontmatter:
---
name: my-skill
description: A skill that does something useful
license: Apache-2.0
compatibility: Requires Python 3.9+
---
# My Skill Instructions
Step-by-step instructions for using this skill...
3.25.6. Skill Activation
Skills are activated lazily.
The system prompt contains only minimal metadata (~50-100 tokens per skill).
When an agent needs a skill, it calls the activate tool to load full instructions.
The Skills class exposes three LLM tools:
-
activate(name)- Load full instructions for a skill -
listResources(skillName, resourceType)- List files in scripts/references/assets -
readResource(skillName, resourceType, fileName)- Read a resource file
3.25.7. Combining Skills with Other References
Skills can be combined with other LlmReference implementations:
var response = context.ai()
.withLlm(properties.chatLlm())
.withReference(
new LocalDirectory("./data/financial", "Financial data files")
.withUsageNotes("Search to find files matching user requests.")
)
.withReference(
new Skills("analytics", "Business analytics skills")
.withGitHubUrl("https://github.com/example/skills/tree/main/analytics")
)
.withSystemPrompt("You are a financial analyst assistant.")
.respond(conversation.getMessages());
3.25.8. Validation
Skills are validated when loaded:
-
Frontmatter validation - Required fields (name, description) and field lengths
-
File reference validation - Paths in instructions (e.g.,
scripts/build.sh) must exist -
Name matching - Skill name must match its parent directory name
To disable file reference validation:
val loader = DefaultDirectorySkillDefinitionLoader(validateFileReferences = false)
3.25.9. Current Limitations
- Script execution
-
Skills with
scripts/directories are loaded, but script execution is not yet supported. A warning is logged when such skills are loaded. - allowed-tools field
-
The
allowed-toolsfrontmatter field is parsed but not currently enforced.
See the Agent Skills Specification for the full specification.
3.26. Testing
Like Spring, Embabel facilitates testing of user applications. The framework provides comprehensive testing support for both unit and integration testing scenarios.
| Building Gen AI applications is no different from building other software. Testing is critical to delivering quality software and must be considered from the outset. |
3.26.1. Unit Testing
Unit testing in Embabel enables testing individual agent actions without involving real LLM calls.
Embabel’s design means that agents are usually POJOs that can be instantiated with fake or mock objects.
Actions are methods that can be called directly with test fixtures.
In additional to your domain objects, you will pass a text fixture for the Embabel OperationContext, enabling you to intercept and verify LLM calls.
The framework provides FakePromptRunner and FakeOperationContext to mock LLM interactions while allowing you to verify prompts, hyperparameters, and business logic.
Alternatively you can use mock objects.
Mockito is the default choice for Java; mockk for Kotlin.
Java Example: Testing Prompts and Hyperparameters
Here’s a unit test from the Java Agent Template repository, using Embabel fake objects:
class WriteAndReviewAgentTest {
@Test
void testWriteAndReviewAgent() {
var context = FakeOperationContext.create();
var promptRunner = (FakePromptRunner) context.promptRunner();
context.expectResponse(new Story("One upon a time Sir Galahad . . "));
var agent = new WriteAndReviewAgent(200, 400);
agent.craftStory(new UserInput("Tell me a story about a brave knight", Instant.now()), context);
String prompt = promptRunner.getLlmInvocations().getFirst().getPrompt();
assertTrue(prompt.contains("knight"), "Expected prompt to contain 'knight'");
var temp = promptRunner.getLlmInvocations().getFirst().getInteraction().getLlm().getTemperature();
assertEquals(0.9, temp, 0.01,
"Expected temperature to be 0.9: Higher for more creative output");
}
@Test
void testReview() {
var agent = new WriteAndReviewAgent(200, 400);
var userInput = new UserInput("Tell me a story about a brave knight", Instant.now());
var story = new Story("Once upon a time, Sir Galahad...");
var context = FakeOperationContext.create();
context.expectResponse("A thrilling tale of bravery and adventure!");
agent.reviewStory(userInput, story, context);
var promptRunner = (FakePromptRunner) context.promptRunner();
String prompt = promptRunner.getLlmInvocations().getFirst().getPrompt();
assertTrue(prompt.contains("knight"), "Expected review prompt to contain 'knight'");
assertTrue(prompt.contains("review"), "Expected review prompt to contain 'review'");
}
}
Kotlin Example: Testing Prompts and Hyperparameters
Here’s the unit test from the Kotlin Agent Template repository:
/**
* Unit tests for the WriteAndReviewAgent class.
* Tests the agent's ability to craft and review stories based on user input.
*/
internal class WriteAndReviewAgentTest {
/**
* Tests the story crafting functionality of the WriteAndReviewAgent.
* Verifies that the LLM call contains expected content and configuration.
*/
@Test
fun testCraftStory() {
// Create agent with word limits: 200 min, 400 max
val agent = WriteAndReviewAgent(200, 400)
val context = FakeOperationContext.create()
val promptRunner = context.promptRunner() as FakePromptRunner
context.expectResponse(Story("One upon a time Sir Galahad . . "))
agent.craftStory(
UserInput("Tell me a story about a brave knight", Instant.now()),
context
)
// Verify the prompt contains the expected keyword
Assertions.assertTrue(
promptRunner.llmInvocations.first().prompt.contains("knight"),
"Expected prompt to contain 'knight'"
)
// Verify the temperature setting for creative output
val actual = promptRunner.llmInvocations.first().interaction.llm.temperature
Assertions.assertEquals(
0.9, actual, 0.01,
"Expected temperature to be 0.9: Higher for more creative output"
)
}
@Test
fun testReview() {
val agent = WriteAndReviewAgent(200, 400)
val userInput = UserInput("Tell me a story about a brave knight", Instant.now())
val story = Story("Once upon a time, Sir Galahad...")
val context = FakeOperationContext.create()
context.expectResponse("A thrilling tale of bravery and adventure!")
agent.reviewStory(userInput, story, context)
val promptRunner = context.promptRunner() as FakePromptRunner
val prompt = promptRunner.llmInvocations.first().prompt
Assertions.assertTrue(prompt.contains("knight"), "Expected review prompt to contain 'knight'")
Assertions.assertTrue(prompt.contains("review"), "Expected review prompt to contain 'review'")
// Verify single LLM invocation during review
Assertions.assertEquals(1, promptRunner.llmInvocations.size)
}
}
Key Testing Patterns Demonstrated
Testing Prompt Content:
-
Use
context.getLlmInvocations().getFirst().getPrompt()to get the actual prompt sent to the LLM -
Verify that key domain data is properly included in the prompt using
assertTrue(prompt.contains(…))
Testing Tool Group Configuration:
-
Access tool groups via
getInteraction().getToolGroups() -
Verify expected tool groups are present or absent as required
Testing with Spring Dependencies:
-
Mock Spring-injected services like
HoroscopeServiceusing standard mocking frameworks - Pass mocked dependencies to agent constructor for isolated unit testing
Testing Multiple LLM Interactions
@Test
void shouldHandleMultipleLlmInteractions() {
// Arrange
var input = new UserInput("Write about space exploration");
var story = new Story("The astronaut gazed at Earth...");
ReviewedStory review = new ReviewedStory("Compelling narrative with vivid imagery.");
// Set up expected responses in order
context.expectResponse(story);
context.expectResponse(review);
// Act
var writtenStory = agent.writeStory(input, context);
ReviewedStory reviewedStory = agent.reviewStory(writtenStory, context);
// Assert
assertEquals(story, writtenStory);
assertEquals(review, reviewedStory);
// Verify both LLM calls were made
List<LlmInvocation> invocations = context.getLlmInvocations();
assertEquals(2, invocations.size());
// Verify first call (writer)
var writerCall = invocations.get(0);
assertEquals(0.8, writerCall.getInteraction().getLlm().getTemperature(), 0.01);
// Verify second call (reviewer)
var reviewerCall = invocations.get(1);
assertEquals(0.2, reviewerCall.getInteraction().getLlm().getTemperature(), 0.01);
}
You can also use Mockito or mockk directory.
Consider this component, using direct injection of Ai:
@Component
public record InjectedComponent(Ai ai) {
public record Joke(String leadup, String punchline) {
}
public String tellJokeAbout(String topic) {
return ai
.withDefaultLlm()
.generateText("Tell me a joke about " + topic);
}
}
A unit test using Mockito to verify prompt and hyperparameters:
class InjectedComponentTest {
@Test
void testTellJokeAbout() {
var mockAi = Mockito.mock(Ai.class);
var mockPromptRunner = Mockito.mock(PromptRunner.class);
var prompt = "Tell me a joke about frogs";
// Yep, an LLM came up with this joke.
var terribleJoke = """
Why don't frogs ever pay for drinks?
Because they always have a tadpole in their wallet!
""";
when(mockAi.withDefaultLlm()).thenReturn(mockPromptRunner);
when(mockPromptRunner.generateText(prompt)).thenReturn(terribleJoke);
var injectedComponent = new InjectedComponent(mockAi);
var joke = injectedComponent.tellJokeAbout("frogs");
assertEquals(terribleJoke, joke);
Mockito.verify(mockAi).withDefaultLlm();
Mockito.verify(mockPromptRunner).generateText(prompt);
}
}
3.26.2. Integration Testing
Integration testing exercises complete agent workflows with real or mock external services while still avoiding actual LLM calls for predictability and speed.
This can ensure:
-
Agents are picked up by the agent platform
-
Data flow is correct within agents
-
Failure scenarios are handled gracefully
-
Agents interact correctly with each other and external systems
-
The overall workflow behaves as expected
-
LLM prompts and hyperparameters are correctly configured
Embabel integration testing is built on top of Spring’s excellent integration testing support, thus allowing you to work with real databases if you wish. Spring’s integration with Testcontainers is particularly userul.
Using EmbabelMockitoIntegrationTest
Embabel provides EmbabelMockitoIntegrationTest as a base class that simplifies integration testing with convenient helper methods:
/**
* Use framework superclass to test the complete workflow of writing and reviewing a story.
* This will run under Spring Boot against an AgentPlatform instance * that has loaded all our agents.
*/ class StoryWriterIntegrationTest extends EmbabelMockitoIntegrationTest {
@Test
void shouldExecuteCompleteWorkflow() {
var input = new UserInput("Write about artificial intelligence");
var story = new Story("AI will transform our world...");
var reviewedStory = new ReviewedStory(story, "Excellent exploration of AI themes.", Personas.REVIEWER);
whenCreateObject(contains("Craft a short story"), Story.class)
.thenReturn(story);
// The second call uses generateText
whenGenerateText(contains("You will be given a short story to review"))
.thenReturn(reviewedStory.review());
var invocation = AgentInvocation.create(agentPlatform, ReviewedStory.class);
var reviewedStoryResult = invocation.invoke(input);
assertNotNull(reviewedStoryResult);
assertTrue(reviewedStoryResult.getContent().contains(story.text()),
"Expected story content to be present: " + reviewedStoryResult.getContent());
assertEquals(reviewedStory, reviewedStoryResult,
"Expected review to match: " + reviewedStoryResult);
verifyCreateObjectMatching(prompt -> prompt.contains("Craft a short story"), Story.class,
llm -> llm.getLlm().getTemperature() == 0.9 && llm.getToolGroups().isEmpty());
verifyGenerateTextMatching(prompt -> prompt.contains("You will be given a short story to review"));
verifyNoMoreInteractions();
}
}
Key Integration Testing Features
Base Class Benefits:
- EmbabelMockitoIntegrationTest handles Spring Boot setup and LLM mocking automatically - Provides agentPlatform and llmOperations pre-configured - Includes helper methods for common testing patterns
Convenient Stubbing Methods:
- whenCreateObject(prompt, outputClass): Mock object creation calls - whenGenerateText(prompt): Mock text generation calls - Support for both exact prompts and contains() matching
Advanced Verification:
- verifyCreateObjectMatching(): Verify prompts with custom matchers - verifyGenerateTextMatching(): Verify text generation calls - verifyNoMoreInteractions(): Ensure no unexpected LLM calls
LLM Configuration Testing:
- Verify temperature settings: llm.getLlm().getTemperature() == 0.9
- Check tool groups: llm.getToolGroups().isEmpty()
- Validate persona and other LLM options
3.28. Troubleshooting
This section covers common issues you might encounter when developing with Embabel and provides practical solutions.
3.28.1. Common Problems and Solutions
| Problem | Solution | Related Docs |
|---|---|---|
Compilation Error |
Check that you’re using the correct version of Embabel in your Maven or Gradle dependencies. You may be using an API from a later version (even a snapshot). Version mismatches between different Embabel modules can cause compilation issues. Ensure all |
|
Don’t Know How to Invoke Your Agent |
Look at examples of processing |
|
Agent Flow Not Completing |
This usually indicates a data flow problem. First, understand Embabel’s type-driven data flow concepts - review how input/output types create dependencies between actions. Then write an integration test to verify your flow works end-to-end. Familiarize yourself with Embabel’s GOAP planning concept. |
|
LLM Prompts Look Wrong or Have Incorrect Hyperparameters |
Write unit tests to capture and verify the exact prompts being sent to your LLM. This allows you to see the actual prompt content and tune temperature, model selection, and other parameters. Unit testing is the best way to debug LLM interactions. |
|
Agent Gets Stuck in Planning |
Check that all your actions have clear input/output type signatures. Missing or circular dependencies in your type flow can prevent the planner from finding a valid path to the goal. Review your |
|
Tools Not Available to Agent |
Ensure you’ve specified the correct |
|
Agent Runs But Produces Poor Results |
Review your prompt engineering and persona configuration. Consider adjusting LLM temperature, model selection, and context provided to actions. Write tests to capture actual vs expected outputs. |
|
You’re Struggling to Express What You Want in a Plan |
Familiarize yourself with custom conditions for complex flow control. For common behavior patterns, consider using atomic actions with Embabel’s typesafe custom builders such as |
|
Custom conditions not working as expected |
Remember that you must declare |
|
Your Agent Has No Goals and Cannot Execute |
Look at the |
|
Your Agent Isn’t Visible to an MCP Client Like Claude Desktop |
Ensure that your |
|
Your Agent Can’t Use Upstream MCP Tools and You’re Seeing Errors in Logs About Possible Misconfiguration |
Check that your Docker configuration is correct if using the default Docker MCP Gateway. Verify that Docker containers are running and accessible. For other MCP configurations, ensure your Spring AI MCP client configuration is correct. See the Spring AI MCP client documentation for detailed setup instructions. |
3.28.3. Getting Help
The Embabel community is active and helpful. Join our Discord server to ask questions and share experiences.
3.29. Migrating from other frameworks
Many people start their journey with Python frameworks.
This section covers how to migrate from popular frameworks when it’s time to use a more robust and secure platform with access to existing code and services.
3.29.1. Migrating from CrewAI
CrewAI uses a collaborative multi-agent approach where agents work together on tasks. Embabel provides similar capabilities with stronger type safety and better integration with existing Java/Kotlin codebases.
Core Concept Mapping
| CrewAI Concept | Embabel Equivalent | Notes |
|---|---|---|
Agent Role/Goal/Backstory |
|
Convenience class for agent personality |
Sequential Tasks |
Typed data flow between actions |
Type-driven execution with automatic planning |
Crew (Multi-agent coordination) |
Actions with shared PromptContributors |
Agents can adopt personalities as needed |
YAML Configuration |
Standard Spring |
Type-safe configuration with validation |
Migration Example
CrewAI Pattern:
research_agent = Agent(
role='Research Specialist',
goal='Find comprehensive information',
backstory='Expert researcher with 10+ years experience'
)
writer_agent = Agent(
role='Content Writer',
goal='Create engaging content',
backstory='Professional writer specializing in technical content'
)
crew = Crew(
agents=[research_agent, writer_agent],
tasks=[research_task, write_task],
process=Process.sequential
)
Embabel Equivalent:
@ConfigurationProperties("examples.book-writer")
record BookWriterConfig(
LlmOptions researcherLlm,
LlmOptions writerLlm,
RoleGoalBackstory researcher,
RoleGoalBackstory writer
) {}
@Agent(description = "Write a book by researching, outlining, and writing chapters")
public record BookWriter(BookWriterConfig config) {
@Action
ResearchReport researchTopic(BookRequest request, OperationContext context) {
return context.ai()
.withLlm(config.researcherLlm())
.withPromptElements(config.researcher(), request)
.withToolGroup(CoreToolGroups.WEB)
.createObject("Research the topic thoroughly...", ResearchReport.class);
}
@Action
BookOutline createOutline(BookRequest request, ResearchReport research, OperationContext context) {
return context.ai()
.withLlm(config.writerLlm())
.withPromptElements(config.writer(), request, research)
.createObject("Create a book outline...", BookOutline.class);
}
@AchievesGoal(export = @Export(remote = true))
@Action
Book writeBook(BookRequest request, BookOutline outline, OperationContext context) {
// Parallel chapter writing with crew-like coordination
var chapters = context.parallelMap(outline.chapterOutlines(),
config.maxConcurrency(),
chapterOutline -> writeChapter(request, outline, chapterOutline, context));
return new Book(request, outline.title(), chapters);
}
}
Key Advantages:
-
Type Safety: Compile-time validation of data flow
-
Spring Integration: Leverage existing enterprise infrastructure
-
Automatic Planning: GOAP planner handles task sequencing, and is capable of more sophisticated planning
-
Tool Integration with the JVM: Native access to existing Java/Kotlin services
3.29.2. Migrating from Pydantic AI
Pydantic AI provides a Python framework for building AI agents with type safety and validation. Embabel offers similar capabilities in the JVM ecosystem with stronger integration into enterprise environments.
Core Concept Mapping
| Pydantic AI Concept | Embabel Equivalent | Notes |
|---|---|---|
@system_prompt decorator |
PromptContributor classes |
More flexible and composable prompt management |
@tool decorator |
Equivalent |
Agent class |
|
Declarative agent definition with Spring integration |
RunContext |
Blackboard state, accessible via |
SystemPrompt |
Custom |
Structured prompt contribution system |
deps parameter |
Spring dependency injection |
Migration Example
Pydantic AI Pattern:
# Based on https://ai.pydantic.dev/examples/bank-support/
from pydantic_ai import Agent, RunContext
from pydantic_ai.tools import tool
@system_prompt
def support_prompt() -> str:
return "You are a support agent in our bank"
@tool
async def get_customer_balance(customer_id: int, include_pending: bool = False) -> float:
# Database lookup
customer = find_customer(customer_id)
return customer.balance + (customer.pending if include_pending else 0)
agent = Agent(
'openai:gpt-4-mini',
system_prompt=support_prompt,
tools=[get_customer_balance],
)
result = agent.run("What's my balance?", deps={'customer_id': 123})
Embabel Equivalent:
// From embabel-agent-examples/examples-java/src/main/java/com/embabel/example/pydantic/banksupport/SupportAgent.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;
}
}
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") int risk) {
}
@Agent(description = "Customer support agent")
record SupportAgent(CustomerRepository customerRepository) {
@AchievesGoal(description = "Help bank customer with their query")
@Action
SupportOutput supportCustomer(SupportInput supportInput, OperationContext context) {
var customer = customerRepository.findById(supportInput.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. In this case, explain why.
Reply using the customer's name, "%s".
Currencies are in $.
Their query: [%s]
""".formatted(customer.name(), supportInput.query()),
SupportOutput.class);
}
}
Key Advantages:
-
Enterprise Integration: Native Spring Boot integration with existing services
-
Compile-time Safety: Strong typing catches errors at build time
-
Automatic Planning: GOAP planner handles complex multi-step operations
-
JVM Ecosystem: Access to mature libraries and enterprise infrastructure
3.29.3. Migrating from LangGraph
LangGraph builds agent workflows using a state machine.
See the blog Build Better Agents in Java vs Python: Embabel vs LangGraph for a detailed comparison of common patterns between LangGraph and Embabel.
3.30. API Evolution
While Embabel is still pre-GA, we strive to avoid breaking changes.
Because Embabel builds on Spring’s POJO support, framework code dependencies are localized and minimized.
The key surface area is the Ai and PromptRunner interfaces, which we will strive to avoid breaking.
For maximum stability:
-
Always use the latest stable version rather than a snapshot. Snapshots may change frequently.
-
Avoid using types under the
com.embabel.agent.experimentalpackage. -
Avoid using any method or class marked with the
@ApiStatus.Experimentalor@ApiStatus.Internalannotations.
application code should not depend on any types under the com.embabel.agent.spi package.
This is intended for provision of runtime infrastructure only, and may change without notice.
|
4. Design Considerations
Embabel is designed to give you the ability to determine the correct balance between LLM autonomy and control from code. This section discusses the design considerations that you can use to achieve this balance.
4.1. Domain objects
A rich domain model helps build a good agentic system. Domain objects should not merely contain state, but also expose behavior. Avoid the anemic domain model. Domain objects have multiple roles:
-
Ensuring type safety and toolability. Code can access their state; prompts will be strongly typed; and LLMs know what to return.
-
Exposing behavior to call in code, exactly as in any well-designed object-oriented system.
-
Exposing tools to LLMs, allowing them to call domain objects.
The third role is novel in the context of LLMs and Embabel.
| When designing your domain objects, consider which methods should be callable by LLMs and which should not. |
Expose methods that LLMs should be able to call using the @Tool annotation:
@Tool(description = "Build the project using the given command in the root") (1)
fun build(command: String): String {
val br = ci.buildAndParse(BuildOptions(command, true))
return br.relevantOutput()
}
| 1 | The Spring AI @Tool annotation indicates that this method is callable by LLMs. |
When an @Action method issues a prompt, tool methods on all domain objects are available to the LLM.
You can also add additional tool methods with the withToolObjects method on PromptRunner.
Domain objects may or may not be persistent. If persistent, they will likely be stored in a familiar JVM technology such as JPA or JDBC. We advocate the use of Spring Data patterns and repositories, although you are free to use any persistence technology you like.
4.3. Mixing LLMs
It’s good practice to use multiple LLMs in your agentic system. Embabel makes it easy. One key benefit of breaking functionality into smaller actions is that you can use different LLMs for different actions, depending on their strengths and weaknesses. You can also the cheapest (greenest) possible LLM for a given task.
5. Contributing
Open source is a wonderful thing. We welcome contributions to the Embabel project.
How to contribute:
-
Familiarize yourself with the project by reading the documentation.
-
Familiarize yourself with the issue tracker and open pull requests to ensure you’re not duplicating something.
-
Always include a description with your pull requests. PRs without descriptions will be closed.
-
Join the Embabel community on Discord at discord.gg/t6bjkyj93q.
Contributions are not limited to code. You can also help by:
-
Improving the documentation
-
Reporting bugs
-
Suggesting new features
-
Engaging with the community on Discord
-
Creating examples and other materials
-
Talking about Embabel at meetups and conferences
-
Posting about Embabel on social media
When contributing code, do augment your productivity using coding agents and LLMs, but avoid these pitfalls:
-
Excessive LLM comments that add no value. Code should be self-documenting. Comments are for things that are non-obvious.
-
Bloated PR descriptions and other content.
Nothing personal, but such contributions will automatically be rejected.
| You must understand anything you contribute. |
6. Resources
6.1. Rod Johnson’s Blog Posts
-
Embabel: A new Agent Platform For the JVM - Introduction to the Embabel agent framework, explaining the motivation for building an agent platform specifically for the JVM ecosystem. Covers the key differentiators and benefits of the approach.
-
The Embabel Vision - Rod Johnson’s vision for the future of agent frameworks and how Embabel fits into the broader AI landscape. Discusses the long-term goals and strategic direction of the project.
-
Context Engineering Needs Domain Understanding - Deep dive into the DICE (Domain-Integrated Context Engineering) concept and why domain understanding is fundamental to effective context engineering in AI systems.
6.2. Examples and Tutorials
-
Creating an AI Agent in Java Using Embabel Agent Framework by Baeldung - A nice introductory example, in Java.
-
Building Agents With Embabel: A Hands-On Introduction by Jettro Coenradie - An excellent Java tutorial.
6.2.1. Embabel Agent Examples Repository
The Examples Repository is a comprehensive collection of example agents demonstrating different aspects of the framework:
-
Beginner Examples: Simple horoscope agents showing basic concepts
-
Intermediate Examples: Multi-LLM research agents with self-improvement
-
Advanced Examples: Fact-checking agents with parallel verification and confidence scoring
-
Integration Examples: Agents that use web tools, databases, and external APIs
Perfect starting point for learning Embabel development with hands-on examples.
6.2.2. Java Agent Template
Template repository for creating new Java-based Embabel agents. Includes:
-
Pre-configured project structure
-
Example WriteAndReviewAgent demonstrating multi-LLM workflows
-
Build scripts and Docker configuration
-
Getting started documentation
6.2.3. Kotlin Agent Template
Template repository for Kotlin-based agent development with similar features to the Java template but using idiomatic Kotlin patterns.
6.3. Sophisticated Example: Tripper Travel Planner
6.3.1. Tripper - AI-Powered Travel Planning Agent
Tripper is a production-quality example demonstrating advanced Embabel capabilities:
Features:
-
Generates personalized travel itineraries using multiple AI models
-
Integrates web search, mapping, and accommodation search
-
Modern web interface built with htmx
-
Containerized deployment with Docker
-
CI/CD pipeline with GitHub Actions
Technical Highlights:
-
Uses both Claude Sonnet and GPT-4.1-mini models
-
Demonstrates domain-driven design principles
-
Shows how to build user-facing applications with Embabel
-
Practical example of deterministic planning with AI
Learning Value:
-
Real-world application of Embabel concepts
-
Integration patterns with external services
-
Production deployment considerations
-
User interface design for AI applications
6.4. Goal-Oriented Action Planning (GOAP)
-
Here’s an Introduction to GOAP, the planning algorithm used by Embabel. Explains the core concepts and why GOAP is effective for AI agent planning.
6.4.1. Small Language Model Agents - NVIDIA Research
-
This Research paper discusses the division between "code agency" and "LLM agency" - concepts that inform Embabel’s architecture.
6.4.2. OODA Loop - Wikipedia
Here’s a Background on the Observe-Orient-Decide-Act loop that underlies Embabel’s replanning approach.
6.5. Domain-Driven Design
-
Martin Fowler’s Foundational concepts of Domain-Driven Design provides a good summary of Embabel’s approach to domain modeling.
6.5.1. Domain-Driven Design: Tackling Complexity in the Heart of Software
-
Eric Evans' seminal book on DDD principles. Essential reading for understanding how to model complex domains effectively.
6.5.2. DDD and Contextual Validation
-
Advanced DDD concepts relevant to building sophisticated domain models for AI agents.
8. Planning Module
8.2. A* GOAP Planner Algorithm Overview
The A* GOAP (Goal-Oriented Action Planning) Planner is an implementation of the A* search algorithm specifically designed for planning sequences of actions to achieve specified goals. The algorithm efficiently finds the optimal path from an initial world state to a goal state by exploring potential action sequences and minimizing overall cost.
8.2.1. Core Algorithm Components
The A* GOAP Planner consists of several key components:
-
A Search*: Finds optimal action sequences by exploring the state space
-
Forward Planning: Simulates actions from the start state toward goals
-
Backward Planning: Optimizes plans by working backward from goals
-
Plan Simulation: Verifies that plans achieve intended goals
-
Pruning: Removes irrelevant actions to create efficient plans
-
Unknown Condition Handling: Manages incomplete world state information
8.2.2. A* Search Algorithm
The A* search algorithm operates by maintaining:
-
Open List: A priority queue of states to explore, ordered by f-score
-
Closed Set: States already fully explored
-
g-score: Cost accumulated so far to reach a state
-
h-score: Heuristic estimate of remaining cost to goal
-
f-score: Total estimated cost (g-score + h-score)
8.2.3. Process Flow
-
Initialization:
-
Begin with the start state in the open list
-
Set its g-score to 0 and calculate its h-score
-
-
Main Loop:
-
While the Open List is not empty:
-
Select the state with the lowest f-score from the open list
-
If this state satisfies the goal, construct and return the plan
-
Otherwise, mark the state as processed (add to closed set)
-
For each applicable action, generate the next state and add to open list if it better than existing paths
-
-
-
Path Reconstruction: When a goal state is found, reconstruct the path by following predecessors
-
Create a plan consisting of the sequence of actions
_Reference: link:goap/AStarGoapPlanner.kt[AStarGoapPlanner]:planToGoalFrom:_
-
8.2.4. Forward and Backward Planning Optimization
The planner implements a two-pass optimization strategy to eliminate unnecessary actions:
Backward Planning Optimization
This pass works backward from the goal conditions to identify only actions that contribute to achieving the goal
_Reference: link:goap/AStarGoapPlanner.kt[AStarGoapPlanner]:_backwardPlanningOptimization___
8.2.5. Pruning Planning Systems
The planner can prune entire planning systems to remove irrelevant actions:
function prune(planningSystem):
// Get all plans to all goals
allPlans = plansToGoals(planningSystem)
// Keep only actions that appear in at least one plan
return planningSystem.copy(
actions = planningSystem.actions.filter { action ->
allPlans.any { plan -> plan.actions.contains(action) }
}.toSet()
)
8.2.6. Complete Planning Process
-
Initialize with start state, actions, and goal conditions
-
Run A* search to find an initial action sequence
-
Apply backward planning optimization to eliminate unnecessary actions
-
Apply forward planning optimization to further refine the plan
-
Verify the plan through simulation
-
Return the optimized action sequence or null if no valid plan exists
8.3. Agent Pruning Process
When pruning an agent for specific goals:
-
Identify all known conditions in the planning system
-
Set initial state based on input conditions
-
Find all possible plans to each goal
-
Keep only actions that appear in at least one plan
-
Create a new agent with the pruned action set
This comprehensive approach ensures agents contain only the actions necessary to achieve their designated goals, improving efficiency and preventing action leakage between different agents.
8.3.1. Progress Determination Logic in A* GOAP Planning
The progress determination logic in method *forwardPlanningOptimization* is a critical part of the forward planning optimization in the A* GOAP algorithm. This logic ensures that only actions that meaningfully progress the state toward the goal are included in the final plan.
Progress Determination Expression
progressMade = nextState != currentState &&
action.effects.any { (key, value) ->
goal.preconditions.containsKey(key) &&
currentState[key] != goal.preconditions[key] &&
(value == goal.preconditions[key] || key not in nextState)
}
Detailed Explanation
The expression evaluates to true only when an action makes meaningful progress toward achieving the goal state. Let's break down each component:
-
nextState != currentState-
Verifies that the action actually changes the world state
-
Prevents including actions that have no effect
-
-
action.effects.any { … }-
Examines each effect the action produces
-
Returns true if ANY effect satisfies the inner condition
-
-
goal.preconditions.containsKey(key)-
Ensures we only consider effects that relate to conditions required by the goal
-
Ignores effects that modify conditions irrelevant to our goal
-
-
currentState[key] != goal.preconditions[key]-
Checks that the current condition value differs from what the goal requires
-
Only counts progress if we’re changing a condition that needs changing
-
-
(value == goal.preconditions[key] || key not in nextState)-
This checks one of two possible ways an action can make progress:
-
value == goal.preconditions[key]-
The action changes the condition to exactly match what the goal requires
-
Direct progress toward goal achievement
-
-
key not in nextState-
The action removes the condition from the state entirely
-
This is considered progress if the condition was previously in an incorrect state
-
Allows for actions that clear obstacles or reset conditions
-
-