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 @LlmTool 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 with methods annotated with Embabel @LlmTool or Spring AI @Tool.
  • At action or PromptRunner level, from a tool group.

LlmReference implementations also expose tools, but this is handled internally by the framework.

In Process Tools: Implementing Tool Instances

Implement one or more methods annotated with @LlmTool 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:

public class MathTools {

    @LlmTool(description = "add two numbers")
    public double add(double a, double b) {
        return 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

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:

@LlmTool(description = "My Tool")
public 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";
}

Receiving Out-of-Band Context in Tools

Tool methods often need access to infrastructure metadata—auth tokens, tenant IDs, correlation IDs—that should not be part of the LLM-facing JSON schema. ToolCallContext provides this: an immutable key-value bag that flows through the tool pipeline without the LLM ever seeing it.

Think of it like HTTP headers on a request. The caller sets them at the boundary (a REST filter, an event handler), and every handler in the chain can read them—but the request body (what the LLM provides) is unaffected.

Injecting ToolCallContext into @LlmTool Methods

Declare a ToolCallContext parameter on any @LlmTool method. The framework will:

  • Inject the current context at call time (or ToolCallContext.EMPTY if none was set)
  • Exclude the parameter from the JSON schema the LLM sees
public class CustomerTools {

    @LlmTool(description = "Look up customer by ID")
    public String lookupCustomer(
            @LlmTool.Param(description = "Customer ID") long customerId,
            ToolCallContext context) {
        String tenantId = context.get("tenantId");
        String authToken = context.get("authToken");
        return customerService.lookup(customerId, tenantId, authToken);
    }
}

The LLM sees only the customerId parameter. The ToolCallContext parameter is invisible in the tool’s schema.

This works for both KotlinMethodTool and JavaMethodTool—the ToolCallContext parameter can appear at any position in the method signature.

Setting Context via ProcessOptions

Context is set at the process boundary using ProcessOptions.withToolCallContext(). It then propagates to every tool invocation in the process—including MCP tools, where it bridges to Spring AI’s ToolContext.

// In a REST controller or event handler
var processOptions = new ProcessOptions()
    .withToolCallContext(Map.of(
        "authToken", request.getHeader("Authorization"),
        "tenantId", request.getHeader("X-Tenant-Id"),
        "correlationId", UUID.randomUUID().toString()
    ));

var invocation = AgentInvocation.builder(agentPlatform)
    .options(processOptions)
    .build(CustomerReport.class);

CustomerReport report = invocation.invoke(customerQuery);

Context Propagation Through Decorators

ToolCallContext flows automatically through decorator chains. Any tool implementing DelegatingTool forwards the context to its delegate by default. Built-in decorators like ArtifactSinkingTool and ReplanningTool follow this pattern, so context reaches the underlying tool without any extra wiring.

Per-Loop One-Shot Tools (OneShotPerLoopTool)

Some tools are meant to fire at most once per agentic loop iteration — typically because the call returns content that, once delivered, lives in the LLM’s conversation history for the rest of the turn. The canonical example is a skill activator: calling it returns the skill body so the LLM can use it; calling it again returns the same body and accomplishes nothing except wasting tokens and a round-trip.

Stronger models follow a system-prompt rule like "call each activator once" reliably. Weaker models (qwen, gpt-oss, smaller open models) reflexively re-call the same tool turn after turn even when the body is already in conversation history. The system-prompt rule isn’t enforceable purely with words — OneShotPerLoopTool makes the constraint mechanical.

Wrap the underlying tool with OneShotPerLoopTool, supplying an advice string that tells the LLM what to do instead of calling again:

Tool gated = new OneShotPerLoopTool(
    githubWorkflowsActivator,
    "Write your script now using the skill body above."
);

The first call within a given loop delegates to the underlying tool as normal. Every subsequent call within the same loop short-circuits with:

ALREADY LOADED. The body of '<tool name>' was returned earlier in this turn —
read it from your conversation history above. Do not call this tool again.
<advice>

Loop scoping is provided by LoopMemo reading ToolCallContext.loopId(), so the orchestrator must stamp a fresh loop id per turn:

val loopId = java.util.UUID.randomUUID().toString()
context.ai()
    .withLlm(myLlm)
    .withToolCallContext(mapOf(ToolCallContext.LOOP_ID_KEY to loopId))
    .withTools(gatedTools)
    .respond(messages)

If no loop id is stamped, `LoopMemo’s documented fallback is "always emit" — every call is treated as the first, so the wrapper degrades to a passthrough rather than silently locking.

Implements DelegatingTool, so the underlying tool is reachable via delegate and the canonical two-arg call overload is the only one a subclass would override.

For the underlying memoisation primitive in isolation (e.g. for "first describe per loop emits the rules block once" inside a tool’s own call), see LoopMemo.

Using Context in Framework-Agnostic Tools

For programmatically created tools, use Tool.ContextAwareFunction to receive context in the handler. The Tool.of() factory method accepts a ContextAwareFunction as the last parameter:

Tool tenantAwareTool = Tool.of(
    "search",
    "Search within tenant scope",
    Tool.InputSchema.of(Tool.Parameter.string("query", "Search query")),
    Tool.Metadata.DEFAULT,
    (Tool.ContextAwareFunction) (input, context) -> {
        String tenantId = context.get("tenantId");
        return Tool.Result.text(searchService.search(input, tenantId));
    }
);

When no context is provided, the function receives ToolCallContext.EMPTY.

If you need to pass context from a web request through to tool invocations, set it once on ProcessOptions and every tool in the process will receive it.

Setting Context per Interaction via PromptRunner

For context that is specific to one LLM call rather than the whole agent run, use withToolCallContext() on the PromptRunner directly inside an @Action method. This is the right place for domain-level metadata that belongs to a particular interaction — for example, which entity the action is working on.

@Action
public RelevantNewsStories findNewsStories(
        StarPerson person, Horoscope horoscope, Ai ai) {

    // Domain-specific context for this interaction only.
    // Flows to all tools invoked during this createObject call,
    // including remote MCP tools where it becomes MCP _meta.
    var interactionContext = ToolCallContext.of(Map.of(
            "personName", person.name(),
            "starSign",   person.sign(),
            "feature",    "star-news-finder"
    ));

    return ai
            .withDefaultLlm()
            .withToolGroup(CoreToolGroups.WEB)
            .withToolCallContext(interactionContext)   // ①
            .createObject(prompt, RelevantNewsStories.class);
}
  1. withToolCallContext() also accepts a plain Map<String, Any> for convenience.

Context Merge Semantics

Context from both sources is merged automatically in ToolLoopLlmOperations.resolveToolCallContext(). Interaction-level values win on conflict.

ProcessOptionsPromptRunnerEffective context
tenantId=acme\{tenantId=acme}
authToken=xyz\{authToken=xyz}
tenantId=acmeauthToken=xyz\{tenantId=acme, authToken=xyz}
tenantId=acmetenantId=override\{tenantId=override} — interaction wins

This means ProcessOptions is the right place for cross-cutting infrastructure concerns (tenant routing, correlation IDs, credentials injected at the gateway), while PromptRunner.withToolCallContext() is the right place for domain-specific per-interaction concerns (which entity the action is working on).

Controlling What Crosses the MCP Boundary

When Embabel calls a remote MCP server, the ToolCallContext entries are forwarded as MCP _meta on the wire. _meta is a first-class field in the MCP 2025-06-18 specification, and MCP server tools can receive it via McpMeta parameters (or Spring AI’s ToolContext).

By default all context entries are forwarded (passThrough behavior). For production deployments calling untrusted third-party MCP servers, register a ToolCallContextMcpMetaConverter bean to control what crosses the process boundary.

Think of it like an HTTP header filter on a reverse proxy: the converter decides which entries are safe to propagate and which should stay local.

// Allowlist — recommended for production: only named keys cross the boundary
@Bean
public ToolCallContextMcpMetaConverter toolCallContextMcpMetaConverter() {
    return ToolCallContextMcpMetaConverter.allowKeys("tenantId", "correlationId", "locale");
}

// Or denylist — forward everything except secrets
@Bean
public ToolCallContextMcpMetaConverter toolCallContextMcpMetaConverter() {
    return ToolCallContextMcpMetaConverter.denyKeys("apiKey", "authToken");
}

// Or custom lambda for arbitrary logic
@Bean
public ToolCallContextMcpMetaConverter toolCallContextMcpMetaConverter() {
    return context -> Map.of(
        "tenantId",    context.get("tenantId"),
        "requestedAt", Instant.now().toString()
    );
}

If no bean is defined, the framework defaults to passThrough() for backward compatibility. The available factory methods are:

MethodBehaviorUse Case
passThrough()Forwards all entriesFully trusted internal MCP servers (default)
noOp()Forwards nothingZero-trust: block all metadata from crossing
allowKeys(vararg keys)Forwards only named keysProduction (recommended): explicit allowlist
denyKeys(vararg keys)Forwards all except named keysWhen secrets are well-known and enumerable

ToolCallContextMcpMetaConverter is the secondary defence for cases where the context is populated at the infrastructure boundary and not all entries should reach third-party servers.

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.

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
public class ToolGroupsConfiguration {

    private final List<McpSyncClient> mcpSyncClients;

    public ToolGroupsConfiguration(List<McpSyncClient> mcpSyncClients) {
        this.mcpSyncClients = mcpSyncClients;
    }

    @Bean
    public MathTools mathToolGroup() {
        return new MathTools();
    }

    @Bean
    public ToolGroup mcpWebToolsGroup() { // ①
        return new McpToolGroup(
            CoreToolGroups.WEB_DESCRIPTION,
            "docker-web",
            "Docker",
            Set.of(ToolGroupPermission.INTERNET_ACCESS),
            mcpSyncClients,
            callback -> {
                // Only expose specific web tools, exclude rate-limited ones
                String name = callback.getToolDefinition().name();
                return (name.contains("brave") || name.contains("fetch")) &&
                       !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.

Using Tools in Action Methods

Tools are specified on the PromptRunner when making LLM calls. This gives you fine-grained control over which tools are available for each specific prompt.

Here’s an example from the StarNewsFinder agent that demonstrates web tool usage within an action:

@Action
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);

    // Tools are specified on the PromptRunner
    return context.ai().withDefaultLlm()
        .withToolGroup(CoreToolGroups.WEB)  // Add web search tools
        .createObject(prompt, RelevantNewsStories.class);
}

Key Tool Usage Patterns

PromptRunner Tool Methods: Tools are added to the PromptRunner fluent API using methods like withToolGroup(), withTools(), and withToolObject().

Multiple Tool Groups: Actions can add multiple tool groups by chaining withToolGroup() calls when they need different types of capabilities.

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.

Additional PromptRunner Examples

// Add tool groups to a specific prompt
context.ai().withAutoLlm().withToolGroup(CoreToolGroups.WEB).create(
    "Given the topic, generate a detailed report using web research.\n\n" +
    "# Topic\n" +
    reportRequest.getTopic()
);

// 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 @LlmTool Methods:

You can also provide domain objects with @LlmTool 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(
        new ToolObject(calculatorService)
            .withNamingStrategy(name -> "calc_" + name)
            .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 @LlmTool or @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

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:

// Simple tool with no parameters
Tool greetTool = Tool.create(
    "greet",
    "Greets the user",
    (input) -> Tool.Result.text("Hello!")
);

// Tool with parameters (using factory methods)
Tool addTool = Tool.create(
    "add",
    "Adds two numbers together",
    Tool.InputSchema.of(
        Tool.Parameter.integer("a", "First number"),
        Tool.Parameter.integer("b", "Second number")
    ),
    (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")
);

The Tool.Parameter class provides factory methods for common parameter types:

  • Tool.Parameter.string(name, description) - String parameter
  • Tool.Parameter.string(name, description, required) - String with optional flag
  • Tool.Parameter.string(name, description, required, enumValues) - String with allowed values
  • Tool.Parameter.integer(name, description) - Integer parameter
  • Tool.Parameter.double(name, description) - Floating-point parameter

All factory methods default to required = true.

Creating Strongly Typed Tools

For tools with complex input and output structures, use Tool.fromFunction() to work with domain objects directly. The input schema is generated automatically from the input type, and JSON marshaling is handled for you.

// Define input and output types
record AddRequest(int a, int b) {}
record AddResult(int sum) {}

// Create typed tool - schema is generated from AddRequest
Tool addTool = Tool.fromFunction(
    "add",
    "Adds two numbers together",
    AddRequest.class,
    AddResult.class,
    input -> new AddResult(input.a() + input.b())
);

// Call the tool - input is deserialized, output is serialized
Tool.Result result = addTool.call("{\"a\": 5, \"b\": 3}");
// Result contains: {"sum":8}

// String output is returned directly (not double-serialized)
Tool greetTool = Tool.fromFunction(
    "greet",
    "Greets someone",
    GreetRequest.class,
    String.class,
    input -> "Hello " + input.name() + "!"
);

// With custom metadata
Tool directTool = Tool.fromFunction(
    "lookup",
    "Looks up data directly",
    LookupRequest.class,
    LookupResult.class,
    Tool.Metadata.create(true), // returnDirect = true
    input -> new LookupResult(findData(input))
);

You can also instantiate TypedTool directly:

val tool = TypedTool(
    name = "add",
    description = "Add two numbers",
    inputType = AddRequest::class.java,
    outputType = AddResult::class.java,
) { input -> AddResult(input.a + input.b) }

Key features of typed tools:

  • Automatic schema generation: The input schema is derived from the input type’s structure
  • JSON marshaling: Input JSON is deserialized to the input type, and output is serialized from the output type
  • String pass-through: If the output type is String, it’s returned directly without JSON serialization
  • Result pass-through: If the function returns a Tool.Result, it’s used as-is
  • Exception handling: Exceptions thrown by the function are converted to Tool.Result.Error
  • Control flow signals: Exceptions implementing ToolControlFlowSignal (like ReplanRequestedException) propagate through

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.

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

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:

// 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);

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

Modifying Tool Descriptions

Tools provide withDescription() and withNote() methods to create copies with modified descriptions. This is useful when you need to customize a tool’s description for a specific context without modifying the original tool.

withDescription(newDescription)

Creates a new tool with a completely replaced description:

// Replace the entire description
Tool customTool = originalTool.withDescription("Custom description for this context");

// The original tool is unchanged
System.out.println(originalTool.getDefinition().getDescription()); // original description
System.out.println(customTool.getDefinition().getDescription());   // Custom description for this context

withNote(note)

Creates a new tool with an appended note to the existing description:

// Add a note to the existing description
Tool annotatedTool = originalTool.withNote("Use this when querying large datasets");

// Result: "Original description. Use this when querying large datasets"
System.out.println(annotatedTool.getDefinition().getDescription());

Both methods preserve all other tool properties (name, input schema, metadata, functionality):

Tool original = Tool.create("calculator", "Performs calculations",
    Tool.InputSchema.of(Tool.Parameter.integer("x", "Number")),
    input -> Tool.Result.text("42"));

// Create a customized version
Tool customized = original
    .withDescription("Specialized math tool")
    .withNote("Optimized for financial calculations");

// Name and functionality unchanged
assert customized.getDefinition().getName().equals("calculator");
assert customized.call("{}").text().equals("42");

When to Use Each Approach

ApproachUse When
Spring AI @ToolYou’re comfortable with Spring AI and want IDE support for tool annotations on domain objects
Tool.create() / Tool.of()You need programmatic tool creation with simple inputs, want framework independence, or are creating tools dynamically
Tool.fromFunction()You need programmatic tool creation with complex typed inputs and outputs, automatic JSON marshaling, and schema generation
@LlmTool / @LlmTool.ParamYou prefer annotation-based tools but want Embabel’s framework-agnostic abstractions
Tool GroupsYou need to organize related tools, use MCP servers, or control tool availability at deployment time

Tool Decoration: Extending Tool Behavior

Embabel uses a powerful decoration pattern to extend tool behavior without modifying the underlying tool or complicating the PromptRunner. A decorated tool wraps another tool, intercepting calls to add functionality like artifact capture, event publishing, or blackboard integration.

This pattern is fundamental to Embabel’s architecture:

  • Subagents use decoration to wrap agent execution as a tool
  • Asset tracking uses decoration to capture tool outputs for chatbot interfaces
  • Blackboard publishing uses decoration to make tool results available to other actions
  • Event streaming uses decoration to publish tool calls to external systems
  • Internal platform features like observability and exception handling also use decoration

The DelegatingTool Interface

All tool decorators implement DelegatingTool:

public interface DelegatingTool extends Tool {
    Tool getDelegate();
}

This allows decorators to be unwrapped when needed, and enables chaining multiple decorators.

ArtifactSinkingTool: Capturing Tool Outputs

ArtifactSinkingTool captures artifacts from Tool.Result.WithArtifact results and sends them to a sink. This is the foundation for making structured tool outputs available elsewhere.

// Capture all artifacts and publish to blackboard
Tool wrapped = Tool.publishToBlackboard(myTool);

// Capture specific types
Tool wrapped = Tool.publishToBlackboard(myTool, SearchResult.class);

// With filtering and transformation
Tool wrapped = Tool.publishToBlackboard(
    myTool,
    SearchResult.class,
    result -> result.getScore() > 0.5,  // filter
    result -> result.getDocument()       // transform
);

// Capture to a custom sink
Tool wrapped = Tool.sinkArtifacts(myTool, SearchResult.class, mySink);

Built-in Sinks

Embabel provides several ArtifactSink implementations:

SinkPurpose
BlackboardSinkPublishes to the current AgentProcess blackboard, making artifacts available to other actions
ListSinkCollects artifacts into a list, useful for aggregating results
CompositeSinkDelegates to multiple sinks, enabling multi-destination publishing

Creating Custom Sinks

Implement ArtifactSink to create custom destinations:

// Publish to an event stream
ArtifactSink eventSink = artifact -> {
    eventPublisher.publish(new ToolArtifactEvent(artifact));
};

// Use with any tool
Tool wrapped = Tool.sinkArtifacts(myTool, MyType.class, eventSink);

How Decoration Enables Extension

The decoration pattern lets Embabel add sophisticated behavior while keeping PromptRunner simple. When you use Subagent.ofClass(MyAgent.class) (Java) or Subagent.ofClass(MyAgent::class.java) (Kotlin), Embabel creates a tool that:

  1. Wraps agent execution in a Tool.call() method
  2. Shares the parent blackboard with the child process
  3. Captures the agent’s result as a tool artifact

Similarly, when you configure asset tracking in a chatbot, Embabel wraps tools with AssetAddingTool to capture outputs as viewable assets.

This approach has key advantages:

  • Composable: Multiple decorators can be chained
  • Transparent: The underlying tool doesn’t know it’s wrapped
  • Extensible: New behaviors can be added without framework changes
  • Type-safe: Generic decorators like ArtifactSinkingTool<T> preserve type information

Subagent: Agent Handoffs as Tools

A Subagent is a specialized Tool that delegates to another Embabel agent. When the LLM invokes this tool, it runs the specified agent as a subprocess, sharing the parent process’s blackboard context. This enables composition of agents and "handoff" patterns where one agent delegates specialized tasks to another.

Creating Subagents

Subagent uses a fluent builder pattern. First select how to reference the agent, then specify the input type using consuming():

// From an @Agent annotated class (most common)
Subagent.ofClass(ConcertAssembler.class).consuming(ConcertPlan.class)

// By agent name (resolved at runtime from platform)
Subagent.byName("ConcertAssembler").consuming(ConcertPlan.class)

// From an already-resolved Agent instance
Subagent.ofInstance(resolvedAgent).consuming(ConcertPlan.class)

// From an instance of an @Agent annotated class (e.g., a Spring bean)
Subagent.ofAnnotatedInstance(myAgentBean).consuming(ConcertPlan.class)

The consuming() method specifies the input type that the LLM will provide when invoking this tool. This type is used to generate the JSON schema that guides the LLM’s tool invocation.

Using Subagents with PromptRunner

Use withTool() to add a Subagent to your prompt:

@Action
public Concert assembleConcert(ConcertPlan plan, OperationContext context) {
    return context.ai()
        .withDefaultLlm()
        .withTool(Subagent.ofClass(PerformanceFinder.class)
                .consuming(WorksToFind.class))  // ①
        .creating(Concert.class)
        .fromPrompt("Assemble a concert based on: " + plan);
}
  1. The LLM can now invoke PerformanceFinder as a tool, providing WorksToFind input to delegate the performance search task.

Subagent with Asset Tracking

For chat applications that track assets, wrap the Subagent with AssetAddingTool to automatically track returned artifacts:

@Action
public Concert assembleConcert(ConcertPlan plan, OperationContext context) {
    var subagent = Subagent.ofClass(PerformanceFinder.class)
            .consuming(WorksToFind.class);
    var trackedSubagent = assetTracker.addReturnedAssets(subagent);  // ①

    return context.ai()
        .withDefaultLlm()
        .withTool(trackedSubagent)
        .creating(Concert.class)
        .fromPrompt("Assemble a concert based on: " + plan);
}

// With filtering - only track certain assets
var trackedSubagent = assetTracker.addReturnedAssets(subagent, asset ->
    asset instanceof Performance  // Only track Performance assets
);
  1. Wrap with addReturnedAssets() to track artifacts returned by the subagent.

Input Type and JSON Schema

The input type you specify with consuming() determines the JSON schema that the LLM sees when invoking the tool.

For example:

// The input type
public record WorksToFind(List<String> composers, String era, int maxResults) {}

// Create the subagent with explicit input type
Subagent.ofClass(PerformanceFinder.class).consuming(WorksToFind.class)

The Subagent tool will:

  • Use "PerformanceFinder" as the tool name (from @Agent annotation)
  • Use "Finds performances" as the tool description (from @Agent annotation)
  • Generate a JSON schema from WorksToFind

From the LLM’s perspective, a Subagent is just another tool. The calling LLM sees the JSON schema for WorksToFind and can populate it directly:

{
  "composers": ["Mozart", "Beethoven"],
  "era": "Classical",
  "maxResults": 5
}

When the tool is invoked, Subagent deserializes this JSON into a WorksToFind object and passes it to the target agent. The input type should match the first non-injected parameter of the agent’s entry-point action.

Blackboard Sharing

When a Subagent runs, it receives a spawned blackboard from the parent process. This means:

  • The subagent can read objects from the parent’s blackboard
  • Objects added by the subagent are available to the parent after the subagent completes
  • The subagent operates in its own process context but shares state appropriately

When to Use Subagent

ScenarioRecommendation
Complex specialized task that has its own multi-action workflowUse Subagent - the target agent can plan and execute multiple steps
Simple tool call with deterministic logicUse a regular @LlmTool method instead
LLM-orchestrated mini-workflow with sub-toolsConsider AgenticTool which operates at the tool level
Need the full power of GOAP planning for the subtaskSubagent is ideal - the target agent uses its own planner

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.

Embabel provides three agentic tool implementations, each offering different levels of control over tool availability:

Choosing an Agentic Tool

Tool TypeTool AvailabilityBest ForExample Use Case
SimpleAgenticToolAll tools available immediatelySimple orchestration, exploration tasksMath calculator with add/multiply/divide tools
PlaybookToolProgressive unlock via conditions (prerequisites, artifacts, blackboard)Structured workflows, guided processesResearch workflow: search → analyze → summarize
StateMachineToolState-based availability using enum statesFormal state machines, multi-phase processesOrder processing: DRAFT → CONFIRMED → SHIPPED → DELIVERED

All three implement the AgenticTool interface and share a common fluent API with with* methods.

The AgenticTool interface defines:

public interface AgenticTool<THIS extends AgenticTool<THIS>> extends Tool {
    LlmOptions getLlm();                              // LLM configuration
    int getMaxIterations();                           // Max tool loop iterations (default: 20)

    THIS withLlm(LlmOptions llm);
    THIS withSystemPrompt(String prompt);
    THIS withSystemPrompt(AgenticSystemPromptCreator creator);  // Dynamic prompt
    THIS withMaxIterations(int maxIterations);
    THIS withParameter(Tool.Parameter parameter);
    THIS withToolObject(Object toolObject);
}

The AgenticSystemPromptCreator functional interface receives both the ExecutingOperationContext (for access to blackboard, process options, etc.) and the input string passed to the tool:

tool.withSystemPrompt((ctx, input) ->
    "Context: " + ctx.getProcessContext().getProcessOptions().getContextId() +
    ". Task: " + input
);

These provide deterministic, typesafe planning that is far more powerful and predictable than LLM-driven orchestration.

SimpleAgenticTool: Flat Tool Orchestration

SimpleAgenticTool makes all sub-tools available immediately. The LLM decides freely which tools to use based on the prompt.

import com.embabel.agent.api.tool.agentic.simple.SimpleAgenticTool;

// Create the agentic tool
SimpleAgenticTool mathOrchestrator = new SimpleAgenticTool("math-orchestrator", "Orchestrates math operations")
    .withTools(addTool, multiplyTool, divideTool)
    .withParameter(Tool.Parameter.string("expression", "Math expression to evaluate"))
    .withLlm(LlmOptions.withModel("gpt-4"));

// Use it like any other tool
context.ai()
    .withDefaultLlm()
    .withTool(mathOrchestrator)
    .generateText("What is 5 + 3 * 2?");

PlaybookTool: Conditional Tool Unlocking

PlaybookTool allows tools to be progressively unlocked based on conditions:

  • Prerequisites: unlock after other tools have been called
  • Artifacts: unlock when certain artifact types are produced
  • Blackboard: unlock based on process state
  • Custom predicates: unlock based on arbitrary conditions
import com.embabel.agent.api.tool.agentic.playbook.PlaybookTool;

// Tools unlock progressively
PlaybookTool researcher = new PlaybookTool("researcher", "Research and analyze topics")
    .withTools(searchTool, fetchTool)                    // Always available
    .withTool(analyzeTool).unlockedBy(searchTool)        // Unlocks after search
    .withTool(summarizeTool).unlockedBy(analyzeTool)     // Unlocks after analyze
    .withParameter(Tool.Parameter.string("topic", "Research topic"));

// Multiple prerequisites (AND)
.withTool(reportTool).unlockedByAll(searchTool, analyzeTool)

// Any prerequisite (OR)
.withTool(processTool).unlockedByAny(searchTool, fetchTool)

// Unlock when artifact type produced
.withTool(formatTool).unlockedByArtifact(Document.class)

// Unlock based on blackboard state
.withTool(actionTool).unlockedByBlackboard(UserProfile.class)

// Custom predicate
.withTool(finalizeTool).unlockedWhen(ctx -> ctx.getIterationCount() >= 3)

When a locked tool is called before its conditions are met, the LLM receives an informative message guiding it to use prerequisite tools first.

StateMachineTool: State-Based Availability

StateMachineTool uses explicit states defined by an enum. Tools are registered with specific states where they’re available, and can trigger transitions to other states.

import com.embabel.agent.api.tool.agentic.state.StateMachineTool;

enum OrderState { DRAFT, CONFIRMED, SHIPPED, DELIVERED }

StateMachineTool<OrderState> orderProcessor = new StateMachineTool<>("orderProcessor", "Process orders", OrderState.class)
    .withInitialState(OrderState.DRAFT)
    .inState(OrderState.DRAFT)
        .withTool(addItemTool)
        .withTool(confirmTool).transitionsTo(OrderState.CONFIRMED)
    .inState(OrderState.CONFIRMED)
        .withTool(shipTool).transitionsTo(OrderState.SHIPPED)
    .inState(OrderState.SHIPPED)
        .withTool(deliverTool).transitionsTo(OrderState.DELIVERED)
    .inState(OrderState.DELIVERED)
        .withTool(reviewTool).build()
    .withGlobalTools(statusTool, helpTool)  // Available in all states
    .withParameter(Tool.Parameter.string("orderId", "Order to process"));

The startingIn(state) method allows starting in a different state at runtime:

// Resume an order that's already confirmed
Tool resumedProcessor = orderProcessor.startingIn(OrderState.CONFIRMED);

Domain Tools: Tools from Retrieved Objects

All three agentic tools support domain tools - @LlmTool methods on domain objects that become available when a single instance is retrieved.

// Domain class with @LlmTool methods
public class User {
    private final String id;
    private final String name;

    public User(String id, String name) {
        this.id = id;
        this.name = name;
    }

    @LlmTool(description = "Get user's profile information")
    public String getProfile() {
        return "Profile for " + name;
    }

    @LlmTool(description = "Update user's settings")
    public String updateSettings(String settings) {
        return "Settings updated for " + name;
    }
}

// Register domain tools - they become available when a single User is retrieved
PlaybookTool userManager = new PlaybookTool("userManager", "Manage users")
    .withTools(searchUserTool, getUserTool)
    .withToolChainingFrom(User.class);  // User methods available after getUserTool returns a single User

Domain tools are "declared" to the LLM immediately but return an error until an instance is bound. When a tool returns a single artifact (not a collection) of a registered type, that instance is bound and its @LlmTool methods become executable.

Creating Agentic Tools

Create agentic tools using the constructor and fluent with* methods:

// 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
SimpleAgenticTool mathOrchestrator = new SimpleAgenticTool("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?");

By default, agentic tools generate 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

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:

// Research tool that requires a topic parameter
SimpleAgenticTool researcher = new SimpleAgenticTool("researcher", "Research a topic thoroughly")
    .withParameter(Tool.Parameter.string("topic", "The topic to research"))
    .withToolObjects(new SearchTools(), new SummarizerTools());

// Calculator with multiple parameters
SimpleAgenticTool calculator = new SimpleAgenticTool("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());

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:

// 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
SimpleAgenticTool assistant = new SimpleAgenticTool("assistant", "Multi-capability assistant")
    .withToolObjects(new SearchTools(), new CalculatorTools());

// With LLM options and custom system prompt
SimpleAgenticTool smartAssistant = new SimpleAgenticTool("smart-assistant", "Smart assistant")
    .withToolObjects(new SearchTools(), new CalculatorTools())
    .withLlm(LlmOptions.withModel("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
public class ResearchOrchestrator {

    private final WebSearchService webSearchService;
    private final SummarizerService summarizerService;

    public ResearchOrchestrator(WebSearchService webSearchService, SummarizerService summarizerService) {
        this.webSearchService = webSearchService;
        this.summarizerService = summarizerService;
    }

    @LlmTool(description = "Search the web for information")
    public List<SearchResult> search(String query) {
        return webSearchService.search(query);
    }

    @LlmTool(description = "Summarize text content")
    public String summarize(String content) {
        return summarizerService.summarize(content);
    }
}

// In your configuration
@Configuration
public class ToolConfiguration {

    @Bean
    public SimpleAgenticTool researchTool(ResearchOrchestrator orchestrator) {
        return new SimpleAgenticTool("research-assistant", "Research topics using web search and summarization")
            .withToolObject(orchestrator)
            .withLlm(new LlmOptions().withRole("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:

SimpleAgenticTool base = new SimpleAgenticTool("base", "Base orchestrator")
    .withTools(tool1)
    .withSystemPrompt("Original prompt");

// Create copies with modifications
SimpleAgenticTool withNewLlm = base.withLlm(new LlmOptions().withModel("gpt-4"));
SimpleAgenticTool withMoreTools = base.withTools(tool2, tool3);
SimpleAgenticTool withNewPrompt = base.withSystemPrompt("Updated prompt");

// Add input parameters
SimpleAgenticTool withParams = base.withParameter(Tool.Parameter.string("query", "Search query"));

// Add tools from an object with @LlmTool methods
SimpleAgenticTool withAnnotatedTools = base.withToolObject(calculatorService);

// Add tools from multiple objects
SimpleAgenticTool withMultipleObjects = base.withToolObjects(searchService, calculatorService);

// Dynamic system prompt based on execution context and input
SimpleAgenticTool withDynamicPrompt = base.withSystemPrompt((ctx, input) -> {
    String contextId = ctx.getProcessContext().getProcessOptions().getContextId().getId();
    return "Process requests for context " + contextId + ". Task: " + input;
});

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
  • withSystemPrompt((ExecutingOperationContext, String) -> String): Set a dynamic prompt based on execution context and input
  • withCaptureNestedArtifacts(Boolean): Control whether artifacts from nested agentic tool calls are captured (default: false)
  • withToolChainingFrom(Class<T>): Register a class whose @LlmTool methods become available when an artifact of that type is returned
  • withToolChainingFrom(Class<T>, DomainToolPredicate<T>): Register with a predicate to filter which instances contribute tools
  • withToolChainingFromAny(): Auto-discover tools from any returned artifact with @LlmTool methods

Controlling Artifact Capture in Nested Agentic Tools

When an agentic tool orchestrates other tools, those sub-tools may return artifacts (via Tool.Result.WithArtifact). By default, artifacts from nested agentic tool calls are not captured—only the final result from the outermost agentic tool is returned.

This prevents intermediate artifacts from bubbling up when you only care about the final result. For example, if an outer assembleConcert tool calls an inner findPerformances tool, you typically want only the final Concert artifact, not all the intermediate Performance artifacts.

Use withCaptureNestedArtifacts(true) if you need to capture artifacts from nested agentic tools:

// Default: nested artifacts are NOT captured
SimpleAgenticTool concertAssembler = new SimpleAgenticTool("assembleConcert", "Assemble a concert program")
    .withTools(findPerformancesTool, createConcertTool);
// Only the Concert artifact from createConcert is returned

// Opt-in: capture all nested artifacts
SimpleAgenticTool fullCapture = concertAssembler.withCaptureNestedArtifacts(true);
// Both Performance artifacts from findPerformances AND Concert from createConcert are captured

Artifacts from regular (non-agentic) tools are always captured.

Tool Chaining

When working with objects returned by tools, you often want to expose @LlmTool methods on those objects as additional tools—but only after the object has been retrieved. The withToolChainingFrom() method enables this pattern.

Tool chaining is available on both AgenticTool and PromptRunner, via the shared ToolChaining interface. This means you can use tool chaining not only in agentic tool loops, but also in simple createObject and generateText calls through PromptRunner. This is significant because it enables any action to dynamically discover and use tools from returned artifacts without requiring a full agentic tool setup.

When you register a class, placeholder tools are created for each @LlmTool method on that class. Initially, these tools return "not available yet" messages. When a tool returns an artifact matching the registered type, the placeholder tools become active and delegate to the bound instance.

Last Wins Semantics: When multiple artifacts of the same type are returned, only the most recent one’s tools are active. This ensures the LLM always works with the "current" instance.

// Domain class with tool methods
public class User {
    private final String id;
    private String email;

    @LlmTool("Update the user's email address")
    public String updateEmail(String newEmail) {
        this.email = newEmail;
        return "Email updated to " + newEmail;
    }
}

// Create agentic tool with tool chaining
SimpleAgenticTool userManager = new SimpleAgenticTool("userManager", "Manage user accounts")
    .withTools(searchUserTool, getUserTool)           // Tools to find/retrieve users
    .withToolChainingFrom(User.class);                // User methods become tools when retrieved

// Flow:
// 1. LLM calls searchUserTool to find users
// 2. LLM calls getUserTool which returns a User artifact
// 3. updateEmail() becomes available as a tool bound to that User
// 4. LLM calls updateEmail("new@example.com")
Predicate-Based Filtering

You can control which instances contribute tools using a predicate. The predicate receives the artifact and the current AgentProcess, allowing filtering based on object state or process context.

// Only expose tools for admin users
SimpleAgenticTool adminManager = new SimpleAgenticTool("adminManager", "Manage admin users")
    .withTools(searchUserTool, getUserTool)
    .withToolChainingFrom(User.class, (user, agentProcess) ->
        user.getRole().equals("admin")
    );

// Regular users won't have their tools exposed
// Only when an admin User is retrieved will updateEmail() become available
Auto-Discovery Mode

For maximum flexibility, use withToolChainingFromAny() to automatically discover and expose tools from any returned artifact that has @LlmTool methods. Unlike registered sources, auto-discovery replaces ALL previous bindings when a new artifact is discovered—ensuring only one "current" object’s tools are active at a time.

// Auto-discover tools from any returned object
SimpleAgenticTool explorer = new SimpleAgenticTool("explorer", "Explore and manipulate objects")
    .withTools(searchTool, getTool)
    .withToolChainingFromAny();  // Tools from any returned object are exposed

// Flow:
// 1. LLM calls getTool which returns a User -> User tools are available
// 2. LLM calls another getTool which returns an Order -> Order tools replace User tools
// 3. Only the most recent object's tools are active

This pattern is useful when:

  • Objects have operations: The object itself knows how to perform actions (e.g., user.updateEmail(), order.cancel())
  • Context-dependent tools: Operations only make sense after retrieving a specific instance
  • Clean API design: Tools are defined on the class rather than as separate tool classes
  • Exploratory workflows: The LLM dynamically works with whatever object is "current"

All agentic tool types support tool chaining:

  • SimpleAgenticTool: Chained tools are available as soon as an artifact is returned
  • PlaybookTool: Chained tools are available immediately (not subject to unlock conditions)
  • StateMachineTool: Chained tools are available globally (not state-bound)
Tool Chaining on PromptRunner

Tool chaining is not limited to agentic tools. Because both AgenticTool and PromptRunner implement the ToolChaining interface, you can use withToolChainingFrom() and withToolChainingFromAny() directly on a PromptRunner obtained from an action’s OperationContext.

This is important because it enables dynamic tool discovery within simple createObject and generateText calls—without requiring a full SimpleAgenticTool wrapper.

// In an @Action method:
PromptRunner ai = context.ai()
    .withToolChainingFrom(User.class)       // Chained tools from User
    .withTools(searchUserTool, getUserTool);

// When getUserTool returns a User artifact, User's @LlmTool methods
// automatically become available for the LLM to call
String result = ai.generateText("Find user Alice and update her email to alice@new.com");

Filtering Artifacts for Asset Tracking

When using tools with an AssetTracker (common in chat applications), you can filter which artifacts become tracked assets. The addReturnedAssets and addAnyReturnedAssets methods accept a Predicate<Asset> filter that works with both Java and Kotlin:

// Track only assets that pass the filter
Tool wrapped = assetTracker.addReturnedAssets(concertTool, asset -> {
    // Only track concerts with at least 3 works
    return asset instanceof Concert concert && concert.getWorks().size() >= 3;
});

// Apply the same filter to multiple tools
List<Tool> wrappedTools = assetTracker.addAnyReturnedAssets(
    List.of(tool1, tool2, tool3),
    asset -> asset.getId().startsWith("important-")
);

The filter is applied after type matching, so you can use type-specific criteria to decide which artifacts are worth tracking.

Migration from Other Frameworks

If you’re coming from frameworks like LangChain or Google ADK, Embabel’s agentic tools provide a familiar pattern similar to their "supervisor" architectures:

FrameworkPatternEmbabel Equivalent
LangChain/LangGraphSupervisor agent with worker agentsSimpleAgenticTool with sub-tools
Google ADKCoordinator with sub_agents / AgentToolSimpleAgenticTool 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:

ScenarioUse This Instead
Business processes with defined outputsGOAP planner - deterministic, goal-oriented planning with preconditions and effects
Exploration and event-driven systemsUtility 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 SimpleAgenticTool for simple cases, PlaybookTool for structured workflows, or StateMachineTool for formal state machines. Graduate to GOAP, Utility, or @State for production workflows where predictability matters.

It operates at a higher level than agentic tools, orchestrating @Action methods rather than Tool instances, and produces typed goal objects with currying support.

Progressive Tools

Great fleas have little fleas upon their backs to bite 'em,
And little fleas have lesser fleas, and so ad infinitum.
And the great fleas themselves, in turn, have greater fleas to go on;
While these again have greater still, and greater still, and so on.

— Augustus De Morgan

Progressive tools enable dynamic tool disclosure—presenting a simplified interface initially, then revealing more granular tools based on context or when the LLM expresses intent.

The Progressive Tool Hierarchy

Embabel provides a hierarchy of progressive tool interfaces:

  • ProgressiveTool: The base interface for tools that can reveal inner tools based on context. Its innerTools(process: AgentProcess) method returns tools that may vary depending on the current agent process state.
  • UnfoldingTool: A ProgressiveTool with a fixed set of inner tools. When invoked, it "unfolds" to reveal its contents—like opening a folded map to see the details inside. This is the most commonly used progressive tool type.

An UnfoldingTool presents a high-level description to the LLM and, when invoked, exposes its inner tools. This pattern is useful for progressive tool disclosure—reducing initial complexity while allowing access to detailed functionality on demand.

When to Use UnfoldingTool

UnfoldingTool is useful when:

  • You have many related tools that might overwhelm the LLM with choices
  • You want to group tools by category (e.g., "database operations", "file operations")
  • You want the LLM to express intent before revealing detailed options
  • You need to reduce token usage for tool descriptions

Creating a Simple UnfoldingTool

The simplest form exposes all inner tools when invoked:

import com.embabel.agent.api.tool.progressive.UnfoldingTool;
import com.embabel.agent.api.tool.Tool;

// Create inner tools
Tool queryTool = Tool.create("query_table", "Execute a SQL query",
    Tool.InputSchema.of(Tool.Parameter.string("sql", "The SQL query to execute")),
    input -> Tool.Result.text("{\"rows\": 5}")
);

Tool insertTool = Tool.create("insert_record", "Insert a new record",
    Tool.InputSchema.of(Tool.Parameter.string("table", "Table name")),
    input -> Tool.Result.text("{\"id\": 123}")
);

Tool deleteTool = Tool.create("delete_record", "Delete a record",
    Tool.InputSchema.of(Tool.Parameter.integer("id", "Record ID to delete")),
    input -> Tool.Result.text("{\"deleted\": true}")
);

// Create the UnfoldingTool facade
var databaseTool = UnfoldingTool.of(
    "database_operations",
    "Use this tool to work with the database. Invoke to see specific operations.",
    List.of(queryTool, insertTool, deleteTool)
);

Fluent Builder API

UnfoldingTool supports a fluent builder pattern for combining tools from multiple sources. Use withTools() to add individual tools or withToolObject() to add tools from @LlmTool annotated objects:

import com.embabel.agent.api.tool.progressive.UnfoldingTool;

// Start with base tools and add more
var combinedTools = UnfoldingTool.of(
        "workspace",
        "Workspace operations. Invoke to see available tools.",
        List.of(baseTool))
    .withTools(searchTool, filterTool)           // Add individual tools
    .withToolObject(new DatabaseOperations())    // Add from @LlmTool class
    .withToolObject(new FileOperations());       // Chain multiple sources

This is useful when:

  • Combining existing tools: Merge tools from different sources into one progressive facade
  • Adding ad-hoc tools: Start with annotated tool classes and add programmatic tools
  • Context-specific grouping: Build different tool combinations for different invocation contexts

The builder preserves all properties (childToolUsageNotes, etc.) from the original UnfoldingTool.

Category-Based Tool Selection

Use byCategory to expose different tools based on the category the LLM selects:

import com.embabel.agent.api.tool.progressive.UnfoldingTool;
import java.util.Map;

// Define tools by category
Map<String, List<Tool>> toolsByCategory = Map.of(
    "read", List.of(readFileTool, listDirectoryTool, searchFilesTool),
    "write", List.of(writeFileTool, deleteFileTool, moveFileTool)
);

// Create category-based UnfoldingTool
var fileTool = UnfoldingTool.byCategory(
    "file_operations",
    "File operations. Pass category: 'read' for reading files, 'write' for modifying files.",
    toolsByCategory
);

// The tool's schema automatically includes the category as an enum parameter
// When invoked with {"category": "read"}, only read tools are exposed
// When invoked with {"category": "write"}, only write tools are exposed

Custom Selection Logic

For more complex selection logic, use selectable:

import com.embabel.agent.api.tool.progressive.UnfoldingTool;
import com.fasterxml.jackson.databind.ObjectMapper;

List<Tool> allTools = List.of(basicTool, advancedTool, adminTool);

var permissionBasedTool = UnfoldingTool.selectable(
    "api_operations",
    "API operations. Pass 'accessLevel': 'basic', 'advanced', or 'admin'.",
    allTools,
    Tool.InputSchema.of(
        Tool.Parameter.string("accessLevel", "Access level for operations",
            true, List.of("basic", "advanced", "admin"))
    ),
    true,  // removeOnInvoke
    input -> {
        // Custom selection logic
        try {
            ObjectMapper mapper = new ObjectMapper();
            Map<String, Object> params = mapper.readValue(input, Map.class);
            String level = (String) params.get("accessLevel");
            return switch (level) {
                case "basic" -> List.of(basicTool);
                case "advanced" -> List.of(basicTool, advancedTool);
                case "admin" -> allTools;
                default -> List.of(basicTool);
            };
        } catch (Exception e) {
            return List.of(basicTool);
        }
    }
);

Guide Tool Behavior

When an UnfoldingTool is invoked, it is replaced by its inner tools plus a guide tool with the same name as the original facade. If the LLM calls the parent tool name again on a subsequent turn (a common tool-calling mistake), the guide tool returns a listing of the available sub-tools instead of failing with a ToolNotFoundException.

This behavior is automatic — no configuration needed. The removeOnInvoke property is deprecated and ignored; the guide tool replacement always applies.

Enabling UnfoldingTool in the Tool Loop

UnfoldingTool is enabled by default when using Embabel’s tool loop. The ToolInjectionStrategy.DEFAULT includes UnfoldingToolInjectionStrategy, so no additional configuration is needed.

If you need to combine with custom strategies, use ChainedToolInjectionStrategy:

import com.embabel.agent.spi.loop.ChainedToolInjectionStrategy;

// Combine UnfoldingTool support with custom strategies
ChainedToolInjectionStrategy combined =
    ChainedToolInjectionStrategy.withUnfolding(customStrategy1, customStrategy2);

How UnfoldingToolWorks

  1. Initial state: The LLM sees only the facade tool (e.g., "database_operations")
  2. LLM invokes: The LLM calls the facade with optional arguments
  3. Strategy evaluates: UnfoldingToolInjectionStrategy detects the invocation
  4. Tools replaced: The facade is replaced by a guide tool and inner tools are added
  5. Continue: The LLM now sees and can use the specific inner tools

This flow reduces the initial tool set complexity while allowing the LLM to access detailed tools when it needs them.

Context Preservation and Usage Notes

When a UnfoldingTool is expanded, its child tools replace the facade. Without context preservation, the LLM would lose important information about why these tools are grouped together.

For example, a "spotify_search" tool containing vector_search, text_search, and regex_search would expand to just three generic search tools - the LLM wouldn’t know these are specifically for searching Spotify music data.

Embabel solves this by automatically injecting a context tool alongside the child tools. This context tool:

  • Preserves the parent’s description ("Search Spotify for music data")
  • Lists the available child tools
  • Includes optional usage notes (via childToolUsageNotes)

The childToolUsageNotes parameter provides guidance on when and how to use the child tools. This guidance appears once in the context tool rather than being duplicated in each child tool’s description:

var spotifySearch = UnfoldingTool.of(
    "spotify_search",
    "Search Spotify for music data including artists, albums, and tracks.",
    List.of(vectorSearchTool, textSearchTool, regexSearchTool),
    true,  // removeOnInvoke
    "Try vector search first for semantic queries like 'upbeat jazz'. " +
    "Use text search for exact artist or album names. " +
    "Use regex search for pattern matching on metadata."
);

After the LLM invokes spotify_search, it will see:

  • vector_search - the actual search tool
  • text_search - the actual search tool
  • regex_search - the actual search tool
  • spotify_search_context - context tool with description and usage notes

The context tool’s description includes the original purpose and available tools. When called, it returns full details about each child tool plus the usage notes - providing a single reference point without polluting individual tool descriptions.

Exclusive Mode

By default, when an UnfoldingTool is expanded, its inner tools are added alongside any sibling tools already in the tool set. In some cases the LLM may ignore the inner tools and instead pick a sibling tool, defeating the purpose of the unfolding.

Setting exclusive = true removes all other tools when the UnfoldingTool is expanded, so the LLM sees only the inner tools until the interaction ends. Use this when the LLM consistently picks the wrong sibling tool instead of using the revealed inner tools.

var personalityTool = UnfoldingTool.of(
    "change_personality",
    "Change the assistant's personality. Invoke to see personality options.",
    List.of(formalTool, casualTool, technicalTool),
    true,   // removeOnInvoke
    null,   // childToolUsageNotes
    true    // exclusive — hide all other tools after expansion
);

When exclusive is false (the default), the parent tool is replaced by its inner tools and all sibling tools remain available. When exclusive is true, every tool in the current tool set is removed and only the inner tools are injected.

Annotation-Based UnfoldingTool

For a more declarative approach, use the @UnfoldingTools class annotation combined with @LlmTool method annotations:

import com.embabel.agent.api.annotation.UnfoldingTools;
import com.embabel.agent.api.annotation.LlmTool;

@UnfoldingTools(
    name = "database_operations",
    description = "Database operations. Invoke to see specific tools."
)
public class DatabaseTools {

    @LlmTool(description = "Execute a SQL query")
    public QueryResult query(String sql) {
        // implementation
    }

    @LlmTool(description = "Insert a record")
    public InsertResult insert(String table, Map<String, Object> data) {
        // implementation
    }

    @LlmTool(description = "Delete a record")
    public void delete(long id) {
        // implementation
    }
}

// Create the UnfoldingTool from the annotated class
var tool = UnfoldingTool.fromInstance(new DatabaseTools());

You can also specify childToolUsageNotes in the annotation to provide guidance on using the child tools:

@UnfoldingTools(
    name = "music_search",
    description = "Search music database for artists, albums, and tracks",
    childToolUsageNotes = "Try vector search first for semantic queries. " +
        "Use text search for exact artist names."
)
public class MusicSearchTools {

    @LlmTool(description = "Semantic search using embeddings")
    public List<Track> vectorSearch(String query) {
        // implementation
    }

    @LlmTool(description = "Exact match text search")
    public List<Track> textSearch(String query) {
        // implementation
    }
}

Category-Based Selection with Annotations

Add category to @LlmTool annotations to automatically create a category-based UnfoldingTool:

@UnfoldingTools(
    name = "file_operations",
    description = "File operations. Pass category: 'read' or 'write'."
)
public class FileTools {

    @LlmTool(description = "Read file contents", category = "read")
    public String readFile(String path) {
        return Files.readString(Path.of(path));
    }

    @LlmTool(description = "List directory contents", category = "read")
    public List<String> listDir(String path) {
        return Files.list(Path.of(path)).map(Path::toString).toList();
    }

    @LlmTool(description = "Write file contents", category = "write")
    public void writeFile(String path, String content) {
        Files.writeString(Path.of(path), content);
    }

    @LlmTool(description = "Delete a file", category = "write")
    public void deleteFile(String path) {
        Files.delete(Path.of(path));
    }
}

// Automatically creates category-based selection
var tool = UnfoldingTool.fromInstance(new FileTools());
// When invoked with {"category": "read"}, only read tools are exposed
// When invoked with {"category": "write"}, only write tools are exposed

@UnfoldingTools Annotation Attributes

AttributeTypeDefaultDescription
nameStringRequiredName of the facade tool the LLM will see
descriptionStringRequiredDescription explaining the tool category
removeOnInvoke (deprecated)booleantrue (ignored — always replaced by guide tool)Whether to remove the facade after invocation
categoryParameterString"category"Name of the parameter for category selection

@LlmTool Category Attribute

The category attribute on @LlmTool is used when the containing class has @UnfoldingTools:

  • Tools with the same category are grouped together
  • Tools without a category are added to all category groups plus an "all" category
  • If no tools have categories, a simple (non-category-based) UnfoldingTool is created

Real-World Example: Spotify Integration

Here’s a real-world example from the Impromptu chatbot that uses @UnfoldingTools to progressively disclose Spotify functionality:

@UnfoldingTools(
    name = "spotify",
    description = "Access Spotify music features. Invoke this tool to enable Spotify " +
            "operations like playing music, searching tracks, managing playlists, " +
            "and controlling playback."
)
public record SpotifyTools(ImpromptuUser user, SpotifyService spotifyService) {

    @LlmTool(description = "Check if user has linked their Spotify account")
    public String checkSpotifyStatus() { /* ... */ }

    @LlmTool(description = "Get the user's Spotify playlists")
    public String getPlaylists() { /* ... */ }

    @LlmTool(description = "Search for tracks on Spotify by song name, artist, or both")
    public String searchTracks(String query) { /* ... */ }

    @LlmTool(description = "Play a track on Spotify by searching for it")
    public String playTrack(String query) { /* ... */ }

    @LlmTool(description = "Pause the current Spotify playback")
    public String pausePlayback() { /* ... */ }

    // ... more tools
}

With this setup:

  1. The LLM initially sees a single spotify tool
  2. When the user says "play some jazz", the LLM invokes spotify
  3. The spotify facade is replaced with all the inner tools (getPlaylists, searchTracks, playTrack, etc.)
  4. The LLM can then call searchTracks or playTrack to fulfill the request

Auto-Detection with Tool.fromInstance()

When you use Tool.fromInstance() on a class annotated with @UnfoldingTools, it automatically creates an UnfoldingTool:

// Auto-detects @UnfoldingTools and creates an UnfoldingTool
List<Tool> tools = Tool.fromInstance(new SpotifyTools(user, service));
// Returns a single UnfoldingTool, not individual tools

This works seamlessly with withToolObject() on PromptRunner:

context.ai()
    .withToolObject(new SpotifyTools(user, spotifyService))
    .respond("Play some classical music");
// The SpotifyTools are automatically exposed as a single UnfoldingTool facade

Wrapping Tool Objects with fromToolObject()

UnfoldingTool.fromInstance() requires the class to be annotated with @UnfoldingTools. This doesn’t work for objects like interface implementations with @LlmTool default methods that you cannot or should not annotate with @UnfoldingTools.

Use fromToolObject() to wrap any object with @LlmTool methods into an UnfoldingTool, providing name and description explicitly:

import com.embabel.agent.api.tool.progressive.UnfoldingTool;

// FileWriteTools is an interface with @LlmTool default methods—
// it cannot be annotated with @UnfoldingTools
FileWriteTools fileTools = new FileWriteToolsImpl(workspace);

var tool = UnfoldingTool.fromToolObject(
    fileTools,
    "file_write_tools",
    "Tools for writing and managing files. Invoke to see specific operations."
);

All standard options are available:

var tool = UnfoldingTool.fromToolObject(
    fileTools,
    "file_write_tools",
    "Tools for writing and managing files.",
    false,                                          // removeOnInvoke
    "Use writeFile for new files, appendFile for existing ones."  // childToolUsageNotes
);

Use fromInstance() when you control the class and can add the @UnfoldingTools annotation.

Nested UnfoldingTools

UnfoldingTools can be nested for multi-level progressive disclosure. This enables organizing large tool collections into logical hierarchies where the LLM navigates by invoking facade tools.

Programmatic Nesting

Use UnfoldingTool.of() to create nested hierarchies programmatically:

// Inner UnfoldingTool for user management
var userManagement = UnfoldingTool.of(
    "user_management",
    "User management operations",
    List.of(createUserTool, deleteUserTool, updateUserTool)
);

// Inner UnfoldingTool for system config
var systemConfig = UnfoldingTool.of(
    "system_config",
    "System configuration operations",
    List.of(updateConfigTool, backupTool, restoreTool)
);

// Outer UnfoldingTool containing both
var adminTool = UnfoldingTool.of(
    "admin_operations",
    "Administrative operations. Invoke to see categories.",
    List.of(userManagement, systemConfig)
);

// Flow:
// 1. LLM sees: admin_operations
// 2. LLM invokes: admin_operations -> sees: user_management, system_config
// 3. LLM invokes: user_management -> sees: createUser, deleteUser, updateUser
Annotation-Based Nesting with Inner Classes

You can also create nested hierarchies using @UnfoldingTools annotations on inner classes. When UnfoldingTool.fromInstance() is called, it automatically discovers and includes any nested inner classes that are also annotated with @UnfoldingTools:

@UnfoldingTools(
    name = "admin_operations",
    description = "Administrative operations. Invoke to access specific areas."
)
public class AdminTools {

    @LlmTool(description = "Get system status")
    public String getStatus() {
        return "System is healthy";
    }

    // Nested inner class - automatically discovered and included as a nested UnfoldingTool
    @UnfoldingTools(
        name = "user_management",
        description = "User management operations. Invoke to see specific tools."
    )
    public static class UserManagement {

        @LlmTool(description = "Create a new user")
        public String createUser(String username) { return "Created user: " + username; }

        @LlmTool(description = "Delete a user")
        public String deleteUser(String username) { return "Deleted user: " + username; }

        // Can nest even deeper
        @UnfoldingTools(
            name = "user_permissions",
            description = "User permission operations"
        )
        public static class Permissions {

            @LlmTool(description = "Grant permission to user")
            public String grant(String user, String permission) { return "Granted"; }

            @LlmTool(description = "Revoke permission from user")
            public String revoke(String user, String permission) { return "Revoked"; }
        }
    }

    @UnfoldingTools(
        name = "system_config",
        description = "System configuration. Invoke to see config tools."
    )
    public static class SystemConfig {

        @LlmTool(description = "Update configuration")
        public String updateConfig(String key, String value) { return "Updated"; }

        @LlmTool(description = "Backup configuration")
        public String backup() { return "Backed up"; }
    }
}

// Create the full nested hierarchy automatically
var adminTool = UnfoldingTool.fromInstance(new AdminTools());

// Flow:
// 1. LLM sees: admin_operations
// 2. LLM invokes: admin_operations -> sees: getStatus, user_management, system_config
// 3. LLM invokes: user_management -> sees: createUser, deleteUser, user_permissions
// 4. LLM invokes: user_permissions -> sees: grant, revoke

This approach provides several benefits:

  • Encapsulation: All related tools are organized in a single class hierarchy
  • Automatic discovery: No manual wiring - inner classes with @UnfoldingTools are automatically included
  • Arbitrary depth: Nest as many levels as needed to organize your tools logically
  • Mixed content: Each level can have both direct @LlmTool methods and nested @UnfoldingTools classes

Dynamically Configured Inner Tools

A powerful pattern with UnfoldingTool.selectable() is creating inner tools that are configured based on the parameters passed when invoking the facade. The selector function can create new tool instances with captured state, connection strings, or other configuration:

// UnfoldingTool that configures database tools based on connection parameter
var databaseTool = UnfoldingTool.selectable(
    "database",
    "Database operations. Pass 'connection' to configure tools.",
    Collections.emptyList(),  // Tools created dynamically
    Tool.InputSchema.of(
        Tool.Parameter.string("connection", "Database connection string")
    ),
    true,  // removeOnInvoke
    input -> {
        // Parse connection from input
        ObjectMapper mapper = new ObjectMapper();
        Map<String, Object> params = mapper.readValue(input, Map.class);
        String connection = (String) params.getOrDefault("connection", "localhost");

        // Create tools configured with the connection string
        return List.of(
            Tool.create("query", "Query database at " + connection, queryInput -> {
                // Tool has captured the connection string
                return Tool.Result.text("Queried " + connection + ": " + queryInput);
            }),
            Tool.create("insert", "Insert into database at " + connection, insertInput -> {
                return Tool.Result.text("Inserted into " + connection);
            })
        );
    }
);

// When LLM invokes with {"connection": "prod-db.example.com"}
// The injected tools are configured to use that specific connection

This pattern is useful for:

  • Multi-tenant systems: Configure tools with tenant-specific credentials or endpoints
  • Environment selection: Let the LLM choose between dev/staging/prod environments
  • Stateful operations: Create tools that share state (like a shopping cart’s item list)
  • Dynamic service discovery: Configure tools based on runtime service locations
Example: Stateful Shopping Cart Tools
var cartTool = UnfoldingTool.selectable(
    "shopping_cart",
    "Shopping cart. Pass 'cart_id' to select which cart to operate on.",
    Collections.emptyList(),
    Tool.InputSchema.of(
        Tool.Parameter.string("cart_id", "Shopping cart ID")
    ),
    true,
    input -> {
        // Each invocation creates a fresh set of tools with shared state
        String cartId = parseCartId(input);
        List<String> cartItems = new ArrayList<>();  // Shared state

        return List.of(
            Tool.create("add_item", "Add item to cart " + cartId,
                Tool.InputSchema.of(Tool.Parameter.string("item", "Item name")),
                itemInput -> {
                    String item = parseItem(itemInput);
                    cartItems.add(item);  // Captured state
                    return Tool.Result.text("Added " + item + ". Total: " + cartItems.size());
                }
            ),
            Tool.create("view_cart", "View cart " + cartId + " contents", viewInput -> {
                return Tool.Result.text("Cart " + cartId + ": " + String.join(", ", cartItems));
            }),
            Tool.create("checkout", "Checkout cart " + cartId, checkoutInput -> {
                String total = calculateTotal(cartItems);
                cartItems.clear();
                return Tool.Result.text("Checked out " + cartId + " for " + total);
            })
        );
    }
);

Comparison with Other Approaches

Other agent frameworks address large tool collections with different approaches, each with trade-offs:

  • Anthropic’s Tool Search Tool: Uses a defer_loading: true flag to prevent tools from being loaded upfront. Tools are discovered via a separate "Tool Search Tool" that searches tool metadata. This requires maintaining searchable tool descriptions and adds latency for each discovery step.
  • LangGraph Dynamic Tool Calling: Uses vector stores and semantic search to select relevant tools based on the user’s query. This requires embedding infrastructure, vector database setup, and careful tuning of similarity thresholds.
  • Google ADK AgentTool: Uses sub-agents that recursively delegate to other agents, each potentially having their own tool sets. Tool discovery is implicit through the agent hierarchy.
  • LangChain4j ToolProvider: Provides a ToolProvider interface for dynamic tool selection, but it works before the LLM call by analyzing the incoming user message. For example, "if the message contains 'booking', include booking tools." This is pre-filtering based on message content, not progressive disclosure through tool invocation. LangChain4j’s documentation also suggests embedding-based classification, RAG over tool descriptions, or two-pass LLM selection—all requiring additional infrastructure or extra LLM calls.

UnfoldingTool takes a fundamentally different approach: invoke to reveal. Instead of searching through tool metadata, the LLM simply invokes a facade tool to unlock the tools it contains.

Beyond Search: Dynamic Tool Configuration

Crucially, UnfoldingTool goes far beyond what any search-based approach can offer. Search can only find pre-existing tools—it cannot create new ones or modify their behavior. With UnfoldingTool.selectable(), the selector function can:

  • Create entirely new tool instances with different implementations based on runtime parameters
  • Capture configuration (connection strings, credentials, endpoints) into the tool’s behavior
  • Share mutable state between the tools created in a single invocation
  • Customize tool descriptions to reflect the specific context of use

For example, when an LLM invokes a "database" UnfoldingTool with {"connection": "prod-db.example.com"}, the returned tools don’t just have different descriptions—they have different behavior that operates on that specific database. This is fundamentally impossible with search-based discovery, which can only return references to pre-defined tools.

This provides several advantages:

AspectOther ApproachesUnfoldingTool
InfrastructureRequires vector stores, embeddings, search indices, or pre-filtering logicNo additional infrastructure required
Selection TimingBefore LLM call (pre-filtering based on message analysis)After LLM decides to invoke a facade (LLM-driven discovery)
LatencySearch/embedding adds latency; two-pass selection doubles LLM callsInstant unlock on invocation
ScalabilitySearch quality degrades with very large tool sets; requires careful tuningScales to any number of tools via nesting without degradation
DeterminismSearch results can vary based on embedding similarityDeterministic: invoking a facade always reveals the same tools
CostEmbedding generation, vector search, or extra LLM calls incur compute costsNo additional compute beyond the tool call itself
Dynamic BehaviorCan only return references to pre-existing toolsCan create new tool instances with runtime-configured behavior

The hierarchical nesting capability of UnfoldingTool means you can organize thousands of tools into a logical tree structure. The LLM navigates this tree by making simple invocations, with no search overhead at any level. For example, a top-level "admin_operations" facade might reveal 5 category facades, each revealing 20 specific tools—giving access to 100 tools with at most 2 invocations.

  • Java

    // Use as LlmReference (adds to system prompt + tools)
    ai.withReference(memory).respond(...);
    
    // Use as Tool directly (just the tool)
    ai.withTool(memory).respond(...);
    
  • Kotlin

    // Use as LlmReference (adds to system prompt + tools)
    ai.withReference(memory).respond(...)
    
    // Use as Tool directly (just the tool)
    ai.withTool(memory).respond(...)
    

    When used as an LlmReference, the tools() method exposes the inner tools directly. When used as a Tool, the implementation wraps them in an UnfoldingTool facade.

Process Introspection Tools

Embabel provides built-in UnfoldingTool implementations for introspecting the current agent process and its blackboard. These tools enable agentic workflows where the LLM can monitor its own progress, check resource usage, and access data from previous steps.

AgentProcessTools: Runtime Awareness

AgentProcessTools provides tools for the LLM to understand its current execution context. This is useful when you want an agent to be aware of its own operational status - for example, to check how much budget remains before undertaking an expensive operation, or to review what actions have been taken so far.

When to use AgentProcessTools:

  • Budget-aware agents: Check remaining cost or token budget before expensive operations
  • Long-running workflows: Monitor elapsed time and action history
  • Debugging and logging: Understand what models and tools have been used
  • Self-reflection: Agents that need to reason about their own behavior

Sub-tools exposed:

Tool NamePurpose
process_statusCurrent process ID, status, running time, and goal information
process_budgetBudget limits (cost, tokens, actions) and remaining capacity
process_costTotal cost (LLM and embedding invocations), invocation counts, and detailed token usage
process_historyList of actions taken so far with execution times
process_tools_statsTool usage statistics (call counts per tool)
process_modelsAll models (LLM and embedding) that have been invoked
import com.embabel.agent.tools.process.AgentProcessTools;
import com.embabel.agent.api.tool.progressive.UnfoldingTool;

// Create the tool - typically added to an agentic tool
var processTools = new AgentProcessTools().create();

// Add to SimpleAgenticTool
var assistant = new SimpleAgenticTool("assistant", "...")
    .withTools(processTools);

If called outside of an agent execution, they return an error message indicating no process is available.

BlackboardTools: Accessing Workflow Data

BlackboardTools provides tools for the LLM to access objects in the current process’s blackboard. The blackboard is Embabel’s shared context mechanism - it holds artifacts from previous actions, tool outputs (when using ArtifactSink), and any other objects bound to the process.

When to use BlackboardTools:

  • Multi-step workflows: Access results from earlier actions without re-execution
  • Tool output access: When tools use ArtifactSink to publish structured data, BlackboardTools lets the LLM retrieve it
  • Context awareness: Let the LLM explore what data is available in the current context
  • Debugging: Inspect blackboard contents during development

Sub-tools exposed:

Tool NamePurpose
blackboard_listList all objects in the blackboard with their types and indices
blackboard_getGet an object by its binding name (e.g., "user", "searchResults")
blackboard_lastGet the most recent object of a given type (matches simple name or FQN)
blackboard_describeGet a detailed description/formatting of an object by binding name
blackboard_countCount the number of objects of a given type in the blackboard
import com.embabel.agent.tools.blackboard.BlackboardTools;
import com.embabel.agent.api.tool.progressive.UnfoldingTool;

// Create with default formatting
var blackboardTools = new BlackboardTools().create();

// Or with custom formatting for blackboard entries
var blackboardTools = new BlackboardTools().create(myCustomFormatter);

// Add to SimpleAgenticTool
var assistant = new SimpleAgenticTool("assistant", "...")
    .withTools(blackboardTools);

Formatting blackboard entries:

By default, BlackboardTools uses DefaultBlackboardEntryFormatter which:

  • Uses infoString() for objects implementing HasInfoString
  • Uses content property for objects implementing HasContent
  • Falls back to toString() for other objects

You can provide a custom BlackboardEntryFormatter to control how objects are presented to the LLM.

Type matching:

The blackboard_last and blackboard_count tools match types by:

  • Simple name: "Person" matches any class named Person
  • Fully qualified name: "com.example.Person" matches that specific class

This flexibility lets the LLM query by whatever name is most convenient.

Combining Process Introspection Tools

For agents that need full situational awareness, combine both tools:

SimpleAgenticTool awarenessAgent = new SimpleAgenticTool(
        "aware_assistant",
        "An assistant that can check its own status and access previous results")
    .withTools(
        new AgentProcessTools().create(),
        new BlackboardTools().create()
    );

Process Communication Tools

Embabel provides two built-in tools that allow the LLM to communicate with the user during agent execution. Both route messages through the current AgentProcess output channel, but differ in their intent and presentation.

ToolPurposePresentation
progressReport transient status updates during long-running workShown as a progress banner (ephemeral)
communicateSend a permanent message to the userShown as an assistant chat bubble (persistent)

ProgressTool

ProgressTool allows the LLM to report what it is currently doing during long-running actions. Progress messages are transient—they indicate activity but are not part of the final conversation output.

import com.embabel.agent.api.tool.ProgressTool;
import com.embabel.agent.api.tool.Tool;

Tool progressTool = ProgressTool.create();

// Add to a SimpleAgenticTool
var assistant = new SimpleAgenticTool("assistant", "...")
    .withTools(progressTool);

When the LLM calls the progress tool, it sends a ProgressOutputChannelEvent to the output channel with a short status message. If no AgentProcess is active on the current thread, the tool logs a warning and returns gracefully—agent execution is not interrupted.

CommunicateTool

CommunicateTool allows the LLM to send a permanent message to the user. Unlike progress updates, communicate messages appear as assistant chat bubbles and remain part of the conversation. Use this for reporting results, sharing links (e.g., PR URLs), or informing the user of important outcomes.

import com.embabel.agent.api.tool.CommunicateTool;
import com.embabel.agent.api.tool.Tool;

Tool communicateTool = CommunicateTool.create();

// Add to a SimpleAgenticTool
var assistant = new SimpleAgenticTool("assistant", "...")
    .withTools(communicateTool);

When the LLM calls the communicate tool, it sends a MessageOutputChannelEvent containing an AssistantMessage to the output channel. Like ProgressTool, it handles the absence of an active AgentProcess gracefully.

Combining Communication Tools

For agents that need both transient progress reporting and persistent messaging, provide both tools:

var assistant = new SimpleAgenticTool(
        "assistant",
        "An assistant that reports progress and communicates results")
    .withTools(
        ProgressTool.create(),
        CommunicateTool.create()
    );

If called outside of an agent execution, they return a soft acknowledgment and log a warning—they do not throw exceptions or interrupt agent execution.

Just-in-Time Tool Group Initialization

By default, Embabel initializes MCP tool groups at application startup. This breaks deployments where MCP servers authenticate requests using the caller’s OAuth token forwarded via the Authorization header, because no user token exists at startup time.

To defer the MCP handshake until the first agent request, set these three properties together:

spring:
  ai:
    mcp:
      client:
        initialized: false
        toolcallback:
          enabled: false

embabel:
  agent:
    platform:
      tools:
        lazy-init: true

With lazy init enabled, the startup log confirms no MCP traffic occurred at startup:

INFO ToolGroupsConfiguration - MCP is available (lazy-init mode). Found 1 client(s).
     Tool groups will be initialized on first use.

The MCP handshake fires only when the first agent action that requires an MCP-backed tool group executes — at which point the user’s OAuth token is already present in the security context.

McpToolFactory: MCP Tool Integration

McpToolFactory is an interface that provides a convenient way to integrate Model Context Protocol (MCP) tools into your application. It creates Embabel Tool instances from MCP servers, with support for filtering tools and wrapping them in UnfoldingTool facades.

SpringAiMcpToolFactory is the Spring AI-based implementation.

Creating McpToolFactory

SpringAiMcpToolFactory requires a list of McpSyncClient instances, which are typically provided by Spring’s MCP auto-configuration:

import com.embabel.agent.tools.mcp.McpToolFactory;
import com.embabel.agent.spi.support.springai.SpringAiMcpToolFactory;
import io.modelcontextprotocol.client.McpSyncClient;

@Configuration
public class ToolConfiguration {

    @Bean
    public McpToolFactory mcpToolFactory(List<McpSyncClient> clients) {
        return new SpringAiMcpToolFactory(clients);
    }
}

See MCP Integration for configuration details.

Getting Individual MCP Tools

Use toolByName to retrieve a single MCP tool by its exact name:

// Returns null if not found
Tool braveSearch = mcpToolFactory.toolByName("brave_web_search");
if (braveSearch != null) {
    ai.withTool(braveSearch).generateText("Search for recent news about AI");
}

// Throws IllegalArgumentException if not found (with helpful error message)
Tool requiredTool = mcpToolFactory.requiredToolByName("brave_web_search");

Creating UnfoldingToolFacades from MCP

McpToolFactory can wrap groups of MCP tools in an UnfoldingTool facade for progressive disclosure. This is useful when you have many MCP tools but want to present them as logical categories.

By Exact Tool Names:

// Create a UnfoldingTool with specific tool names
var wikipediaTool = mcpToolFactory.unfoldingByName(
    "wikipedia",
    "Search and find content from Wikipedia",
    Set.of("search_wikipedia", "get_article", "get_related_topics", "get_summary")
);

By Regex Patterns:

import java.util.regex.Pattern;

// Match tools by regex patterns
var dbTool = mcpToolFactory.unfoldingMatching(
    "database_operations",
    "Database operations. Invoke to access database tools.",
    List.of(Pattern.compile("^db_.*"), Pattern.compile(".*query.*"))
);

With Custom Filter:

// Custom filter predicate
var webTool = mcpToolFactory.unfolding(
    "web_operations",
    "Web operations. Invoke to access web tools.",
    callback -> callback.getToolDefinition().name().startsWith("web_")
);

Controlling Facade Removal

After invocation, UnfoldingTool facades created by McpToolFactory are replaced by a guide tool and their inner tools. The removeOnInvoke parameter is deprecated and ignored:

// Keep facade even after invocation
var persistentTool = mcpToolFactory.unfoldingByName(
    "wikipedia",
    "Search Wikipedia",
    Set.of("search_wikipedia", "get_article"),
    false  // removeOnInvoke = false
);

Real-World Example: Chatbot with MCP Tools

Here’s a real-world example from a production chatbot that uses McpToolFactory to integrate MCP tools with graceful degradation:

@Configuration
public class ChatConfiguration {

    @Bean
    public McpToolFactory mcpToolFactory(List<McpSyncClient> clients) {
        return new SpringAiMcpToolFactory(clients);
    }

    @Bean
    public CommonTools commonTools(McpToolFactory mcpToolFactory) {
        var deferMessage = "Use this tool only after trying local sources";
        var tools = new LinkedList<>();

        // Single MCP tool - gracefully handle missing tools
        var braveSearch = mcpToolFactory.toolByName("brave_web_search");
        if (braveSearch != null) {
            tools.add(braveSearch.withNote(deferMessage));
        }

        // UnfoldingTool grouping related Wikipedia MCP tools
        var wikipediaTool = mcpToolFactory.unfoldingByName(
            "wikipedia",
            "Search and find content from Wikipedia: " + deferMessage,
            Set.of("search_wikipedia", "get_article", "get_related_topics", "get_summary")
        );
        if (!wikipediaTool.getInnerTools().isEmpty()) {
            tools.add(wikipediaTool);
        }

        return new CommonTools(tools);
    }
}

This pattern:

  • Gracefully degrades when MCP tools aren’t available (e.g., in test environments)
  • Groups related tools behind a descriptive facade using UnfoldingTool
  • Adds usage hints with withNote() to guide the LLM on when to use external tools
  • Checks for empty results before adding tools to avoid empty facades

McpToolFactory Method Summary

MethodDescription
toolByName(String)Get a single MCP tool by exact name. Returns null if not found.
requiredToolByName(String)Get a single MCP tool by exact name. Throws IllegalArgumentException if not found, with a helpful error message listing available tools.
unfoldingByName(name, description, toolNames)Create an UnfoldingTool containing tools with exact matching names.
unfoldingMatching(name, description, patterns)Create an UnfoldingTool containing tools matching any of the regex patterns.
unfolding(name, description, filter)Create an UnfoldingTool with a custom filter predicate.

Was this page helpful?

Share