315px Meister der Weltenchronik 001

Embabel Agent Release: 0.3.1

© 2024-2025 Embabel

Rod Johnson, Alex Hein-Heifetz, Dr. Igor Dayen, Jim Clark, Arjen Poutsma, Jasper Blues

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 @Action methods, 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.3.7. Designed for Testability

Both unit testing and agent end-to-end testing are easy from the ground up.

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:
  • Precondition: "A UserInput object must be available"

  • Required Data: The agent needs user input to proceed

  • Capability: This action can extract structured data from unstructured input

6 Output Condition Creation: Returning StarPerson creates:
  • Postcondition: "A StarPerson object is now available in the world state"

  • Data Availability: This output becomes input for subsequent actions

  • Type Safety: The domain model enforces structure

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:
  • Precondition: "A StarPerson object must exist" (from previous action)

  • Dependency: This action can only execute after extractStarPerson completes

  • Service Integration: Uses the injected horoscopeService rather than an LLM

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 → need writeup() action

  • writeup() requires StarPerson, RelevantNewsStories, and Horoscope

  • To get StarPerson → need extractStarPerson() action

  • To get Horoscope → need retrieveHoroscope() action (requires StarPerson)

  • To get RelevantNewsStories → need findNewsStories() action (requires StarPerson and Horoscope)

  • extractStarPerson() requires UserInput → must be provided by user

Execution sequence:

UserInputextractStarPerson()StarPersonretrieveHoroscope()HoroscopefindNewsStories()RelevantNewsStorieswriteup()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:

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)

Anthropic (Claude 3.x, etc.)
  • Required:

    • ANTHROPIC_API_KEY: API key for Anthropic services

DeepSeek
  • Required:

    • DEEPSEEK_API_KEY: API key for DeepSeek services

  • Optional:

Google Gemini (OpenAI Compatible)

Uses the OpenAI-compatible endpoint for Gemini models.

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:

  • -p logs prompts

  • -r logs 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 Ai object is injected like any other Spring dependency using constructor injection

  • Simple API: Access AI capabilities through the Ai interface directly or OperationContext.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:

  • @Action methods are the steps the agent can take

  • @AchievesGoal marks the final action that completes the agent’s work

Domain Objects:

  • Story and ReviewedStory are 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:

  1. Generate a creative story using the writer LLM

  2. Review and improve it using the reviewer LLM

  3. Return the final reviewed story

2.5.5. Next Steps

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:

  1. Analyze Current State: Examine the current blackboard contents and world state

  2. Identify Available Actions: Find all actions that can be executed based on their preconditions

  3. Search for Action Sequences: Use A* algorithm to find optimal paths to achieve the goal

  4. Select Optimal Plan: Choose the best action sequence based on cost and success probability

  5. 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:

  1. Input Processing: Initial user input is added to the blackboard

  2. Action Execution: Each action reads inputs from blackboard and adds results

  3. State Evolution: Blackboard accumulates objects representing the evolving state

  4. Planning Input: Current blackboard state informs the next planning cycle

  5. 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.3. Goals, Actions and Conditions

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

embabel.models.default-llm

String

gpt-4.1-mini

Default LLM name. It’s good practice to override this in configuration.

embabel.models.default-embedding-model

String

null

Default embedding model name. Need not be set, in which case it defaults to null.

embabel.models.llms

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.

embabel.models.embedding-services

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

embabel.agent.platform.name

String

embabel-default

Core platform identity name

embabel.agent.platform.description

String

Embabel Default Agent Platform

Platform description

Logging Personality

Configuration for agent logging output style and theming.

Property Type Default Description

embabel.agent.logging.personality

String

(none)

Themed logging messages to add personality to agent output

Table 1. Available Personality Values
Value Description

starwars

Star Wars themed logging messages

severance

Severance themed logging messages. Praise Kier

colossus

Colossus: The Forbin Project themed messages

hitchhiker

Hitchhiker’s Guide to the Galaxy themed messages

montypython

Monty Python themed logging messages

Example Configuration
embabel:
  agent:
    logging:
      personality: hitchhiker
Agent Scanning

From AgentPlatformProperties.ScanningConfig - configures scanning of the classpath for agents.

Property Type Default Description

embabel.agent.platform.scanning.annotation

Boolean

true

Whether to auto register beans with @Agent and @Agentic annotation

embabel.agent.platform.scanning.bean

Boolean

false

Whether to auto register as agents Spring beans of type Agent

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

embabel.agent.platform.ranking.llm

String

null

Name of the LLM to use for ranking, or null to use auto selection

embabel.agent.platform.ranking.max-attempts

Int

5

Maximum number of attempts to retry ranking

embabel.agent.platform.ranking.backoff-millis

Long

100

Initial backoff time in milliseconds

embabel.agent.platform.ranking.backoff-multiplier

Double

5.0

Multiplier for backoff time

embabel.agent.platform.ranking.backoff-max-interval

Long

180000

Maximum backoff time in milliseconds

LLM Operations

From AgentPlatformProperties.LlmOperationsConfig - configuration for LLM operations including prompts and data binding.

Property Type Default Description

embabel.agent.platform.llm-operations.prompts.maybe-prompt-template

String

maybe_prompt_contribution

Template for "maybe" prompt, enabling failure result when LLM lacks information

embabel.agent.platform.llm-operations.prompts.generate-examples-by-default

Boolean

true

Whether to generate examples by default

embabel.agent.platform.llm-operations.data-binding.max-attempts

Int

10

Maximum retry attempts for data binding

embabel.agent.platform.llm-operations.data-binding.fixed-backoff-millis

Long

30

Fixed backoff time in milliseconds between retries

Process ID Generation

From AgentPlatformProperties.ProcessIdGenerationConfig - configuration for process ID generation.

Property Type Default Description

embabel.agent.platform.process-id-generation.include-version

Boolean

false

Whether to include version in process ID generation

embabel.agent.platform.process-id-generation.include-agent-name

Boolean

false

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

embabel.agent.platform.autonomy.agent-confidence-cut-off

Double

0.6

Confidence threshold for agent operations

embabel.agent.platform.autonomy.goal-confidence-cut-off

Double

0.6

Confidence threshold for goal achievement

Model Provider Configuration

From AgentPlatformProperties.ModelsConfig - model provider integration configurations.

Anthropic
Property Type Default Description

embabel.agent.platform.models.anthropic.max-attempts

Int

10

Maximum retry attempts

embabel.agent.platform.models.anthropic.backoff-millis

Long

5000

Initial backoff time in milliseconds

embabel.agent.platform.models.anthropic.backoff-multiplier

Double

5.0

Backoff multiplier

embabel.agent.platform.models.anthropic.backoff-max-interval

Long

180000

Maximum backoff interval in milliseconds

OpenAI
Property Type Default Description

embabel.agent.platform.models.openai.max-attempts

Int

10

Maximum retry attempts

embabel.agent.platform.models.openai.backoff-millis

Long

5000

Initial backoff time in milliseconds

embabel.agent.platform.models.openai.backoff-multiplier

Double

5.0

Backoff multiplier

embabel.agent.platform.models.openai.backoff-max-interval

Long

180000

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

embabel.agent.platform.models.googlegenai.max-attempts

Int

10

Maximum retry attempts

embabel.agent.platform.models.googlegenai.backoff-millis

Long

5000

Initial backoff time in milliseconds

embabel.agent.platform.models.googlegenai.backoff-multiplier

Double

5.0

Backoff multiplier

embabel.agent.platform.models.googlegenai.backoff-max-interval

Long

180000

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

GOOGLE_API_KEY

API key for Google AI Studio authentication

GOOGLE_PROJECT_ID

Google Cloud project ID (for Vertex AI authentication)

GOOGLE_LOCATION

Google Cloud region, e.g., us-central1 (for Vertex AI authentication)

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

embabel.agent.platform.sse.max-buffer-size

Int

100

Maximum buffer size for SSE

embabel.agent.platform.sse.max-process-buffers

Int

1000

Maximum number of process buffers

Test Configuration

From AgentPlatformProperties.TestConfig - test configuration.

Property Type Default Description

embabel.agent.platform.test.mock-mode

Boolean

true

Whether to enable mock mode for testing

Process Repository Configuration

From ProcessRepositoryProperties - configuration for the agent process repository.

Property Type Default Description

embabel.agent.platform.process-repository.window-size

Int

1000

Maximum number of agent processes to keep in memory when using default InMemoryAgentProcessRepository. When exceeded, oldest processes are evicted.

Standalone LLM Configuration
LLM Operations Prompts

From LlmOperationsPromptsProperties - properties for ChatClientLlmOperations operations.

Property Type Default Description

embabel.llm-operations.prompts.maybe-prompt-template

String

maybe_prompt_contribution

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

embabel.llm-operations.prompts.generate-examples-by-default

Boolean

true

Whether to generate examples by default

embabel.llm-operations.prompts.default-timeout

Duration

60s

Default timeout for operations

LLM Data Binding

From LlmDataBindingProperties - data binding properties with retry configuration for LLM operations.

Property Type Default Description

embabel.llm-operations.data-binding.max-attempts

Int

10

Maximum retry attempts for data binding

embabel.llm-operations.data-binding.fixed-backoff-millis

Long

30

Fixed backoff time in milliseconds between retries

Additional Model Providers
AWS Bedrock

From BedrockProperties - AWS Bedrock model configuration properties.

Property Type Default Description

embabel.models.bedrock.models

List

[]

List of Bedrock models to configure

embabel.models.bedrock.models[].name

String

""

Model name

embabel.models.bedrock.models[].knowledge-cutoff

String

""

Knowledge cutoff date

embabel.models.bedrock.models[].input-price

Double

0.0

Input token price

embabel.models.bedrock.models[].output-price

Double

0.0

Output token price

Docker Local Models

From DockerProperties - configuration for Docker local models (OpenAI-compatible).

Property Type Default Description

embabel.docker.models.base-url

String

localhost:12434/engines

Base URL for Docker model endpoint

embabel.docker.models.max-attempts

Int

10

Maximum retry attempts

embabel.docker.models.backoff-millis

Long

2000

Initial backoff time in milliseconds

embabel.docker.models.backoff-multiplier

Double

5.0

Backoff multiplier

embabel.docker.models.backoff-max-interval

Long

180000

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

embabel.agent.platform.migration.scanning.enabled

Boolean

false

Whether deprecated property scanning is enabled (disabled by default for production safety)

embabel.agent.platform.migration.scanning.include-packages

List<String>

["com.embabel.agent", "com.embabel.agent.shell"]

Base packages to scan for deprecated conditional annotations

embabel.agent.platform.migration.scanning.exclude-packages

List<String>

Extensive default list

Package prefixes to exclude from scanning

embabel.agent.platform.migration.scanning.additional-excludes

List<String>

[]

Additional user-specific packages to exclude

embabel.agent.platform.migration.scanning.auto-exclude-jar-packages

Boolean

false

Whether to automatically exclude JAR-based packages using classpath detection

embabel.agent.platform.migration.scanning.max-scan-depth

Int

10

Maximum depth for package scanning

embabel.agent.platform.migration.warnings.individual-logging

Boolean

true

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 @Cost methods must be nullable (use @Nullable in Java or ? in Kotlin)

  • When a domain object is not available on the blackboard, null is passed instead of causing the method to fail

  • The method must return a double between 0.0 and 1.0

  • The Blackboard can 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 the org.springframework.lang.Nullable or another Nullable annotation.

  • Infrastructure parameters, such as the OperationContext, ProcessContext, and Ai may 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 OperationContext unless 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 ActionMethodArgumentResolver implementations.

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 ActionContext to 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

RunSubagent.fromAnnotatedInstance()

When you have an @Agent-annotated instance and don’t need ActionContext

RunSubagent.fromAnnotatedInstance(new SubAgent(), Result.class)

RunSubagent.instance()

When you have an Agent object

RunSubagent.instance(agent, Result.class)

ActionContext.asSubProcess()

When you need access to ActionContext for other operations

context.asSubProcess(Result.class, agent)

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 of ScatterGather.

  • 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 via embabel.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 the ObjectCreator fluent 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 PromptRunner by providing one or more in process tool instances. A tool instance is an object annotated with @Tool methods.

  • At action or PromptRunner level, 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).

— Spring AI
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 @Tool

You’re comfortable with Spring AI and want IDE support for tool annotations on domain objects

Tool.create() / Tool.of()

You need programmatic tool creation, want framework independence, or are creating tools dynamically

@LlmTool / @LlmTool.Param

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:

  1. The tool retrieves the current AgentProcess context

  2. It configures a PromptRunner with the specified LlmOptions

  3. It adds all sub-tools to the prompt runner

  4. It executes the prompt with the input, allowing the LLM to orchestrate the sub-tools

  5. 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 (use Tool.Parameter.string(), .integer(), .double())

  • withLlm(LlmOptions): Set LLM configuration

  • withTools(vararg Tool): Add additional Tool instances

  • withToolObject(Any): Add tools from an object with @LlmTool methods

  • 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

AgenticTool with sub-tools

Google ADK

Coordinator with sub_agents / AgentTool

AgenticTool with sub-tools

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 in notes

  • 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-code module)

  • com.embabel.coding.tools.api.ApiReferenceProvider: API from classpath (embabel-agent-code module)

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 PromptContributor classes 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:

  1. Start with a broad search and narrow down

  2. Try different phrasings if initial queries return poor results

  3. Expand promising results to get more context

  4. 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:

  1. Inspects Store Capabilities: Examines which SearchOperations subinterfaces the store implements

  2. Exposes Appropriate Tools: Only creates tool wrappers for supported operations

  3. 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 splitting LeafSection content

Chunks

Chunk is the primary unit for vector search. Each chunk:

  • Contains a text field with the content

  • Has a parentId linking to its source section

  • Includes metadata with information about its origin (root document, container section, leaf section)

  • Can compute its pathFromRoot through 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.

CoreSearchOperations

A convenience interface combining the most common search capabilities:

interface CoreSearchOperations : VectorSearch, TextSearch

Stores that support both vector and text search can implement this single interface for convenience.

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.

Some content here

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 VectorSearch since that’s its strength

  • A full-text search engine might implement TextSearch and RegexSearchOperations

  • A hierarchical document store might add ResultExpander for 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 AgentProcess can 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:

  1. The chatbot adds the UserMessage to both the Conversation and the AgentProcess blackboard

  2. The planner evaluates all actions whose trigger conditions are satisfied

  3. For utility planning, the action with the highest value (lowest cost) is selected

  4. 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.yml without 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.13. The AgentProcess

An AgentProcess is created every time an agent is run. It has a unique id.

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. EarlyTerminationPolicy can 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:

  1. Clears the blackboard - Only the returned state object remains

  2. Re-plans from the new state - The planner considers only actions available in the new state

  3. 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 @State interface are state types

  • Abstract classes: Classes extending a @State abstract class are state types

  • Concrete classes: Classes extending a @State class 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 @AchievesGoal methods 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:

  1. craftStory executes with LLM, returns AssessStory → blackboard cleared, enters AssessStory state

  2. getFeedback calls WaitFor.formSubmission() → agent pauses, waits for user input

  3. User submits feedback → HumanFeedback added to blackboard

  4. assess executes with LLM to interpret feedback:

    • If acceptable: returns Done → enters Done state

    • If not acceptable: returns ReviseStory → enters ReviseStory state

  5. If in ReviseStory: reviseStory executes with LLM, returns AssessStory → loop back to step 2

  6. When in Done: reviewStory executes with LLM, returns ReviewedStory → 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:

  1. The agent process enters a WAITING state

  2. A form is generated based on the HumanFeedback record structure

  3. The user sees the prompt and fills out the form

  4. Upon submission, the HumanFeedback instance is created and added to the blackboard

  5. 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)

  • @State is 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 @AchievesGoal on terminal state actions

  • Use WaitFor for 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:

  1. Which preconditions are satisfied (type availability + SpEL conditions)

  2. The cost and value parameters 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:

  1. An entry action classifies input and returns a @State type

  2. Each @State class contains an @AchievesGoal action that produces the final output

  3. The @AchievesGoal output is not a @State type (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:

  1. The triageTicket action classifies it into one of the state types

  2. Entering a state clears other objects from the blackboard

  3. The Utility planner selects the @AchievesGoal action for that state

  4. The goal is achieved when ResolvedTicket is 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 MarketData can only run after an action produces MarketData. This is deterministic but rigid.

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:

LangGraph (Python)
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
Embabel (Kotlin)
@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 (MarketDataRequest, Ai) and MarketDataRequest is already on the blackboard, we "curry out" that parameter—​the tool exposed to the LLM only needs to provide any remaining parameters. This simplifies the LLM’s task and signals which tools are "ready" to run.

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

  1. Type constraints: Actions can only produce specific types—​no arbitrary string outputs

  2. Input filtering: Tools unavailable until their input types exist

  3. Schema guidance: LLM sees what each action produces, not just descriptions

  4. 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:

Stages.java - Actions as @EmbabelComponent
@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
        );
    }
}
Invoking with SupervisorInvocation
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:

  1. Available actions with their type signatures and schemas

  2. Current artifacts on the blackboard (including UserInput content)

  3. 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
  1. Actions use UserInput explicitly: Each action receives UserInput and includes it in the LLM prompt, ensuring the actual user request is used.

  2. @AchievesGoal marks the target: The prepareMeal action is marked with @AchievesGoal to indicate it produces the final output.

  3. Type-driven dependencies: prepareMeal requires Cook and Order, 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

@EmbabelComponent or multiple components

Single @Agent class

Best for

Quick prototypes, simple workflows

Formalized, reusable agents

Goal specification

.returning(Class) fluent method

@AchievesGoal on action

Scope

Explicit via AgentScopeBuilder

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 Tool instances

@Action methods from @EmbabelComponent

Output

Tool.Result (text, artifact, or error)

Typed goal object (e.g., Meal)

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

  • StreamingPromptRunnerBuilder - runner with streaming capabilities

  • 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)

  • OptionsConverter to convert Embabel LlmOptions to Spring AI ChatOptions

  • knowledge cutoff date (if available)

  • any additional PromptContributor objects to be used in all LLM calls. If knowledge cutoff date is provided, add the KnowledgeCutoffDate prompt 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 mcpo proxy 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). The PerGoalMcpToolExportCallbackPublisher automatically 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
  • AbstractMcpServerConfiguration provides 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.

Docker Tools Integration
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

brave-search-mcp, fetch-mcp, wikipedia-mcp, or docker-mcp

Web search, URL fetching, Wikipedia queries

Maps

google-maps-mcp or docker-mcp

Geocoding, directions, place search

Browser Automation

puppeteer-mcp or docker-mcp

Page navigation, screenshots, form interaction

GitHub

github-mcp or docker-mcp

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
Custom Tool Groups

Define custom groups via configuration properties:

embabel:
  agent:
    platform:
      tools:
        includes:
          my-tools:
            description: "Custom tool collection"
            provider: "MyOrg"
            tools:
              - tool_name_suffix

3.24.2. A2A

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:

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-tools frontmatter 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 HoroscopeService using 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.27. Embabel Architecture

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 com.embabel.agent artifacts use the same version, unless you’re following a specific example that does otherwise.

Configuration

Don’t Know How to Invoke Your Agent

Look at examples of processing UserInput in the documentation. Study AgentInvocation patterns to understand how to trigger your agent flows. The key is understanding how to provide the initial input that your agent expects.

Invoking Agents

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.

Data Flow Concepts

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.

Testing

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 @Action method signatures. Look at the log output from the planner for clues. Set your ProcessOptions.verbosity to show planning.

Type-Driven Flow

Tools Not Available to Agent

Ensure you’ve specified the correct toolGroups in your @Action annotation. Tools must be explicitly declared for the action to access them. Check that required tool groups are available in your environment.

Tools

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.

Testing, LLM Configuration

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 ScatterGatherBuilder and RepeatUntilBuilder instead of trying to express everything through individual actions.

DSL and Builders

Custom conditions not working as expected

Remember that you must declare post conditions on @Action methods that may set your custom condition, as well as pre conditions on actions that depend on them. Otherwise the planner will assume that the conditions are never set and your plan will not execute as expected.

Type-Driven Flow

Your Agent Has No Goals and Cannot Execute

Look at the @AchievesGoal annotation and ensure your terminal action is annotated with it. Every agent needs at least one action marked with @AchievesGoal to define what constitutes completion of the agent’s work.

Annotations

Your Agent Isn’t Visible to an MCP Client Like Claude Desktop

Ensure that your @AchievesGoal annotation includes @Export(remote=true). This makes your agent available for remote invocation through MCP (Model Context Protocol) clients.

Annotations, Integrations

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.

Spring AI MCP Client

3.28.2. Debugging Strategies

Enable Debug Logging

Customize Embabel logging in application.yml or application.properties to see detailed agent execution. For example:

logging:
  level:
    com.embabel.agent: DEBUG

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

RoleGoalBackstory PromptContributor

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 @ConfigurationProperties backed by application.yml or profile-specific configuration files

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 @Tool annotated methods can be included on agent classes and domain objects

Agent class

@Agent annotated record/class

Declarative agent definition with Spring integration

RunContext

Blackboard state, accessible via OperationContext but normally not a concern for user code

SystemPrompt

Custom PromptContributor

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.29.4. Migrating from Google ADK

tbd

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.experimental package.

  • Avoid using any method or class marked with the @ApiStatus.Experimental or @ApiStatus.Internal annotations.

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:

  1. Ensuring type safety and toolability. Code can access their state; prompts will be strongly typed; and LLMs know what to return.

  2. Exposing behavior to call in code, exactly as in any well-designed object-oriented system.

  3. 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.2. Tool Call Choice

When to use MCP or other tools versus method calls in agents

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.

  • Sign your commits

  • 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

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

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

7. APPENDIX

8. Planning Module

8.1. Abstract

Lower level module for planning and scheduling. Used by Embabel Agent Platform.

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:
  1. A Search*: Finds optimal action sequences by exploring the state space

  2. Forward Planning: Simulates actions from the start state toward goals

  3. Backward Planning: Optimizes plans by working backward from goals

  4. Plan Simulation: Verifies that plans achieve intended goals

  5. Pruning: Removes irrelevant actions to create efficient plans

  6. 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

  1. Initialization:

    • Begin with the start state in the open list

    • Set its g-score to 0 and calculate its h-score

  2. 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

  3. 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___
Forward Planning Optimization
This pass simulates the plan from the start state and removes actions that don't make progress
toward the goal:
_Reference: link:goap/AStarGoapPlanner.kt[AStarGoapPlanner]:_forwardPlanningOptimization___
Plan Simulation
Plan simulation executes actions in sequence to verify the plan's correctness:
_Reference: function simulatePlan(startState, actions)_

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()
  )
Heuristic Function
The heuristic function estimates the cost to reach the goal from a given state:

8.2.6. Complete Planning Process

  1. Initialize with start state, actions, and goal conditions

  2. Run A* search to find an initial action sequence

  3. Apply backward planning optimization to eliminate unnecessary actions

  4. Apply forward planning optimization to further refine the plan

  5. Verify the plan through simulation

  6. Return the optimized action sequence or null if no valid plan exists

8.3. Agent Pruning Process

When pruning an agent for specific goals:
  1. Identify all known conditions in the planning system

  2. Set initial state based on input conditions

  3. Find all possible plans to each goal

  4. Keep only actions that appear in at least one plan

  5. 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:
  1. nextState != currentState

    • Verifies that the action actually changes the world state

    • Prevents including actions that have no effect

  2. action.effects.any { …​ }

    • Examines each effect the action produces

    • Returns true if ANY effect satisfies the inner condition

  3. 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

  4. 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

  5. (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