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
PromptRunnerby providing one or more in process tool instances. A tool instance is an object with methods annotated with Embabel@LlmToolor Spring AI@Tool. - At action or
PromptRunnerlevel, from a tool group.
LlmReference implementations also expose tools, but this is handled internally by the framework.
In Process Tools: Implementing Tool Instances
Section titled “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}class MathTools {
@LlmTool(description = "add two numbers") fun add(a: Double, b: Double) = a + b
// Other tools}Classes implementing tools can be stateful. They are often domain objects. Tools on mapped entities are especially useful, as they can encapsulate state that is never exposed to the LLM. See Domain Tools: Direct Access, Zero Ceremony for a discussion of tool use patterns.
The @Tool annotation comes from Spring AI.
Tool methods can have any visibility, and can be static or instance scope. They are allowed on inner classes.
You can define any number of arguments for the method (including no argument) with most types (primitives, POJOs, enums, lists, arrays, maps, and so on). Similarly, the method can return most types, including void. If the method returns a value, the return type must be a serializable type, as the result will be serialized and sent back to the model.
The following types are not currently supported as parameters or return types for methods used as tools:
- Optional
- Asynchronous types (e.g. CompletableFuture, Future)
- Reactive types (e.g. Flow, Mono, Flux)
- Functional types (e.g. Function, Supplier, Consumer).
— Spring AI
Tool Calling
You can obtain the current AgentProcess in a Tool method implementation via AgentProcess.get().
This enables tools to bind to the AgentProcess, making objects available to other actions.
For example:
@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";}@LlmTool(description = "My Tool")fun bindCustomer(id: Long): String { val customer = customerRepository.findById(id) val agentProcess = AgentProcess.get() return if (agentProcess != null) { agentProcess.addObject(customer) "Customer bound to blackboard" } else { "No agent process: Unable to bind customer" }}Receiving Out-of-Band Context in Tools
Section titled “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
Section titled “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.EMPTYif 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); }}class CustomerTools {
@LlmTool(description = "Look up customer by ID") fun lookupCustomer( @LlmTool.Param(description = "Customer ID") customerId: Long, context: ToolCallContext, ): String { val tenantId = context.get<String>("tenantId") val authToken = context.get<String>("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
Section titled “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 handlervar 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);// In a REST controller or event handlerval processOptions = ProcessOptions() .withToolCallContext(ToolCallContext.of( "authToken" to request.getHeader("Authorization"), "tenantId" to request.getHeader("X-Tenant-Id"), "correlationId" to UUID.randomUUID().toString(), ))
val invocation = AgentInvocation.builder(agentPlatform) .options(processOptions) .build<CustomerReport>()
val report = invocation.invoke(customerQuery)Context Propagation Through Decorators
Section titled “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)
Section titled “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.");val gated = OneShotPerLoopTool( delegate = githubWorkflowsActivator, advice = "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
Section titled “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)); });val tenantAwareTool = Tool.of( name = "search", description = "Search within tenant scope", inputSchema = Tool.InputSchema.of(Tool.Parameter.string("query", "Search query")),) { input: String, context: ToolCallContext -> val tenantId = context.get<String>("tenantId") Tool.Result.text(searchService.search(input, tenantId))}When no context is provided, the function receives ToolCallContext.EMPTY.
Setting Context per Interaction via PromptRunner
Section titled “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.
@Actionpublic 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) (1) .createObject(prompt, RelevantNewsStories.class);}@Actionfun findNewsStories(person: StarPerson, horoscope: Horoscope, ai: Ai): RelevantNewsStories {
val interactionContext = ToolCallContext.of( "personName" to person.name, "starSign" to person.sign, "feature" to "star-news-finder", )
return ai .withDefaultLlm() .withToolGroup(CoreToolGroups.WEB) .withToolCallContext(interactionContext) (1) .createObject(prompt, RelevantNewsStories::class.java)}withToolCallContext()also accepts a plainMap<String, Any>for convenience.
Context Merge Semantics
Section titled “Context Merge Semantics”Context from both sources is merged automatically in ToolLoopLlmOperations.resolveToolCallContext().
Interaction-level values win on conflict.
| ProcessOptions | PromptRunner | Effective context |
| --- | --- | --- |
| tenantId=acme | — | {tenantId=acme} |
| — | authToken=xyz | {authToken=xyz} |
| tenantId=acme | authToken=xyz | {tenantId=acme, authToken=xyz} |
| tenantId=acme | tenantId=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
Section titled “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@Beanpublic ToolCallContextMcpMetaConverter toolCallContextMcpMetaConverter() { return ToolCallContextMcpMetaConverter.allowKeys("tenantId", "correlationId", "locale");}
// Or denylist — forward everything except secrets@Beanpublic ToolCallContextMcpMetaConverter toolCallContextMcpMetaConverter() { return ToolCallContextMcpMetaConverter.denyKeys("apiKey", "authToken");}
// Or custom lambda for arbitrary logic@Beanpublic ToolCallContextMcpMetaConverter toolCallContextMcpMetaConverter() { return context -> Map.of( "tenantId", context.get("tenantId"), "requestedAt", Instant.now().toString() );}// Allowlist — recommended for production@Beanfun toolCallContextMcpMetaConverter() = ToolCallContextMcpMetaConverter.allowKeys("tenantId", "correlationId", "locale")
// Or denylist@Beanfun toolCallContextMcpMetaConverter() = ToolCallContextMcpMetaConverter.denyKeys("apiKey", "authToken")
// Or custom lambda@Beanfun toolCallContextMcpMetaConverter() = ToolCallContextMcpMetaConverter { context -> mapOf( "tenantId" to (context.get<String>("tenantId") ?: "unknown"), "requestedAt" to Instant.now().toString(), )}If no bean is defined, the framework defaults to passThrough() for backward compatibility.
The available factory methods are:
| Method | Behavior | Use Case |
| --- | --- | --- |
| passThrough() | Forwards all entries | Fully trusted internal MCP servers (default) |
| noOp() | Forwards nothing | Zero-trust: block all metadata from crossing |
| allowKeys(vararg keys) | Forwards only named keys | Production (recommended): explicit allowlist |
| denyKeys(vararg keys) | Forwards all except named keys | When secrets are well-known and enumerable |
Tool Groups
Section titled “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.
Tool groups are often backed by MCP.
Configuring Tool Groups in configuration files
Section titled “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: - weatherConfiguring Tool Groups in Spring @Configuration
Section titled “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:
@Configurationpublic 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() { (1) 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"); } ); }}@Configurationclass ToolGroupsConfiguration( private val mcpSyncClients: List<McpSyncClient>) {
@Bean fun mathToolGroup() = MathTools()
@Bean fun mcpWebToolsGroup(): ToolGroup { (1) return McpToolGroup( description = CoreToolGroups.WEB_DESCRIPTION, name = "docker-web", provider = "Docker", permissions = setOf(ToolGroupPermission.INTERNET_ACCESS), clients = mcpSyncClients, filter = { // Only expose specific web tools, exclude rate-limited ones (it.toolDefinition.name().contains("brave") || it.toolDefinition.name().contains("fetch")) && !it.toolDefinition.name().contains("brave_local_search") } ) }}- 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
Section titled “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
Section titled “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.
@Configurationpublic class MyToolGroupsConfiguration {
@Bean public ToolGroup databaseToolsGroup(DataSource dataSource) { return new DatabaseToolGroup(dataSource); }
@Bean public ToolGroup emailToolsGroup(EmailService emailService) { return new EmailToolGroup(emailService); }}@Configurationclass MyToolGroupsConfiguration {
@Bean fun databaseToolsGroup(dataSource: DataSource): ToolGroup = DatabaseToolGroup(dataSource)
@Bean fun emailToolsGroup(emailService: EmailService): ToolGroup = 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
Section titled “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:
@Actionpublic 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);}@Actioninternal fun findNewsStories( person: StarPerson, horoscope: Horoscope, context: OperationContext,): RelevantNewsStories = context.ai().withDefaultLlm() .withToolGroup(CoreToolGroups.WEB) // Add web search tools .withToolGroup(CoreToolGroups.BROWSER_AUTOMATION) // Add browser tools .createObject( """ ${person.name} is an astrology believer with the sign ${person.sign}. Their horoscope for today is: <horoscope>${horoscope.summary}</horoscope> Given this, use web tools and generate search queries to find $storyCount relevant news stories summarize them in a few sentences. Include the URL for each story. Do not look for another horoscope reading or return results directly about astrology; find stories relevant to the reading above. """.trimIndent() )Key Tool Usage Patterns
Section titled “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
Section titled “Additional PromptRunner Examples”// Add tool groups to a specific promptcontext.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 groupscontext.ai().withDefaultLlm() .withToolGroup(CoreToolGroups.WEB) .withToolGroup(CoreToolGroups.MATH) .createObject("Calculate stock performance with web data", StockReport.class);// Add tool groups to a specific promptcontext.ai().withAutoLlm().withToolGroup(CoreToolGroups.WEB).create( """ Given the topic, generate a detailed report using web research.
# Topic ${reportRequest.topic} """.trimIndent())
// Add multiple tool groupscontext.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 strategycontext.ai() .withDefaultLlm() .withToolObject( new ToolObject(calculatorService) .withNamingStrategy(name -> "calc_" + name) .withFilter(methodName -> methodName.startsWith("compute")) ).createObject("Perform calculations", Result.class);context.ai() .withDefaultLlm() .withToolObject(jokerTool) .createObject("Create a UserInput object for fun", UserInput::class.java)
// Add tool object with filtering and custom naming strategycontext.ai() .withDefaultLlm() .withToolObject( ToolObject(calculatorService) .withNamingStrategy { "calc_$it" } .withFilter { methodName -> methodName.startsWith("compute") } ).createObject("Perform calculations", Result::class.java)Available PromptRunner Tool Methods:
withToolGroup(String): Add a single tool group by namewithToolGroup(ToolGroup): Add a specific ToolGroup instancewithToolGroups(Set<String>): Add multiple tool groupswithTools(vararg String): Convenient method to add multiple tool groupswithToolObject(Any): Add domain object with@LlmToolor@ToolmethodswithToolObject(ToolObject): Add ToolObject with custom configurationwithTool(Tool): Add a framework-agnostic Tool instancewithTools(List<Tool>): Add multiple framework-agnostic Tool instances
Framework-Agnostic Tool Interface
Section titled “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 acceptsTool.Parameter- A single parameter with name, type, and descriptionTool.Result- The result returned by a tool (text, artifact, or error)Tool.Handler- Functional interface for implementing tool logic
Creating Tools Programmatically
Section titled “Creating Tools Programmatically”You can create tools using the Tool.create() factory methods:
// Simple tool with no parametersTool 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"));// Simple tool with no parametersval greetTool = Tool.of( name = "greet", description = "Greets the user") { _ -> Tool.Result.text("Hello!")}
// Tool with parameters (using factory methods)val addTool = Tool.of( name = "add", description = "Adds two numbers together", inputSchema = Tool.InputSchema.of( Tool.Parameter.integer("a", "First number"), Tool.Parameter.integer("b", "Second number") )) { input -> // Parse input JSON and compute result Tool.Result.text("42")}
// Tool with metadataval directTool = Tool.of( name = "lookup", description = "Looks up data directly", metadata = Tool.Metadata(returnDirect = true)) { _ -> Tool.Result.text("Direct result")}The Tool.Parameter class provides factory methods for common parameter types:
Tool.Parameter.string(name, description)- String parameterTool.Parameter.string(name, description, required)- String with optional flagTool.Parameter.string(name, description, required, enumValues)- String with allowed valuesTool.Parameter.integer(name, description)- Integer parameterTool.Parameter.double(name, description)- Floating-point parameter
All factory methods default to required = true.
Creating Strongly Typed Tools
Section titled “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 typesrecord AddRequest(int a, int b) {}record AddResult(int sum) {}
// Create typed tool - schema is generated from AddRequestTool 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 serializedTool.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 metadataTool directTool = Tool.fromFunction( "lookup", "Looks up data directly", LookupRequest.class, LookupResult.class, Tool.Metadata.create(true), // returnDirect = true input -> new LookupResult(findData(input)));// Define input and output typesdata class AddRequest(val a: Int, val b: Int)data class AddResult(val sum: Int)
// Create typed tool - uses reified types for cleaner syntaxval addTool = Tool.fromFunction<AddRequest, AddResult>( name = "add", description = "Adds two numbers together",) { input -> AddResult(input.a + input.b) }
// Call the tool - input is deserialized, output is serializedval result = addTool.call("""{"a": 5, "b": 3}""")// Result contains: {"sum":8}
// String output is returned directly (not double-serialized)val greetTool = Tool.fromFunction<GreetRequest, String>( name = "greet", description = "Greets someone",) { input -> "Hello ${input.name}!" }
// With custom metadataval directTool = Tool.fromFunction<LookupRequest, LookupResult>( name = "lookup", description = "Looks up data directly", metadata = Tool.Metadata(returnDirect = true),) { input -> 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(likeReplanRequestedException) propagate through
Creating Tools from Annotated Methods
Section titled “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 instanceList<Tool> mathTools = Tool.fromInstance(new MathService());
// Or safely create tools (returns empty list if no annotations found)List<Tool> tools = Tool.safelyFromInstance(someObject);class MathService {
@LlmTool(description = "Adds two numbers together") fun add( @LlmTool.Param(description = "First number") a: Int, @LlmTool.Param(description = "Second number") b: Int, ): Int = a + b
@LlmTool(description = "Multiplies two numbers") fun multiply( @LlmTool.Param(description = "First number") a: Int, @LlmTool.Param(description = "Second number") b: Int, ): Int = a * b}
// Create tools from all annotated methods on an instanceval mathTools = Tool.fromInstance(MathService())
// Or safely create tools (returns empty list if no annotations found)val tools = Tool.safelyFromInstance(someObject)The @LlmTool annotation supports:
name: Tool name (defaults to method name if empty)description: Description of what the tool does (required)returnDirect: Whether to return the result directly without further LLM processing
The @LlmTool.Param annotation supports:
description: Description of the parameter (helps the LLM understand what to provide)required: Whether the parameter is required (defaults to true)
Adding Framework-Agnostic Tools via PromptRunner
Section titled “Adding Framework-Agnostic Tools via PromptRunner”Use withTool() or withTools() to add framework-agnostic tools to a PromptRunner:
// Add a single toolTool 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 methodsList<Tool> mathTools = Tool.fromInstance(new MathService());
context.ai() .withDefaultLlm() .withTools(mathTools) .createObject("Add 5 and 3", MathResult.class);
// Combine with other tool sourcescontext.ai() .withDefaultLlm() .withToolGroup(CoreToolGroups.WEB) // Tool group .withToolObject(domainObject) // Spring AI @Tool methods .withTools(mathTools) // Framework-agnostic tools .createObject("Research and calculate", Report.class);// Add a single toolval calculatorTool = Tool.of("calculate", "Performs calculations") { _ -> Tool.Result.text("Result: 42")}
context.ai() .withDefaultLlm() .withTool(calculatorTool) .createObject("Calculate 6 * 7", MathResult::class.java)
// Add tools from annotated methodsval mathTools = Tool.fromInstance(MathService())
context.ai() .withDefaultLlm() .withTools(mathTools) .createObject("Add 5 and 3", MathResult::class.java)
// Combine with other tool sourcescontext.ai() .withDefaultLlm() .withToolGroup(CoreToolGroups.WEB) // Tool group .withToolObject(domainObject) // Spring AI @Tool methods .withTools(mathTools) // Framework-agnostic tools .createObject("Research and calculate", Report::class.java)Tool Results
Section titled “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 resultTool.Result.error("Failed to process request", exception);// 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 resultTool.Result.error("Failed to process request", exception)Modifying Tool Descriptions
Section titled “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 descriptionTool customTool = originalTool.withDescription("Custom description for this context");
// The original tool is unchangedSystem.out.println(originalTool.getDefinition().getDescription()); // original descriptionSystem.out.println(customTool.getDefinition().getDescription()); // Custom description for this context// Replace the entire descriptionval customTool = originalTool.withDescription("Custom description for this context")
// The original tool is unchangedprintln(originalTool.definition.description) // original descriptionprintln(customTool.definition.description) // Custom description for this contextwithNote(note)
Creates a new tool with an appended note to the existing description:
// Add a note to the existing descriptionTool annotatedTool = originalTool.withNote("Use this when querying large datasets");
// Result: "Original description. Use this when querying large datasets"System.out.println(annotatedTool.getDefinition().getDescription());// Add a note to the existing descriptionval annotatedTool = originalTool.withNote("Use this when querying large datasets")
// Result: "Original description. Use this when querying large datasets"println(annotatedTool.definition.description)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 versionTool customized = original .withDescription("Specialized math tool") .withNote("Optimized for financial calculations");
// Name and functionality unchangedassert customized.getDefinition().getName().equals("calculator");assert customized.call("{}").text().equals("42");val original = Tool.of( name = "calculator", description = "Performs calculations", inputSchema = Tool.InputSchema.of(Tool.Parameter.integer("x", "Number"))) { Tool.Result.text("42") }
// Create a customized versionval customized = original .withDescription("Specialized math tool") .withNote("Optimized for financial calculations")
// Name and functionality unchangedcheck(customized.definition.name == "calculator")check(customized.call("{}").text == "42")When to Use Each Approach
Section titled “When to Use Each Approach”| Approach | Use When |
| --- | --- |
| Spring AI @Tool | You’re comfortable with Spring AI and want IDE support for tool annotations on domain objects |
| Tool.create() / Tool.of() | You need programmatic tool creation 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.Param | You prefer annotation-based tools but want Embabel’s framework-agnostic abstractions |
| Tool Groups | You need to organize related tools, use MCP servers, or control tool availability at deployment time |
Tool Decoration: Extending Tool Behavior
Section titled “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
Section titled “The DelegatingTool Interface”All tool decorators implement DelegatingTool:
public interface DelegatingTool extends Tool { Tool getDelegate();}interface DelegatingTool : Tool { val delegate: Tool}This allows decorators to be unwrapped when needed, and enables chaining multiple decorators.
ArtifactSinkingTool: Capturing Tool Outputs
Section titled “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 blackboardTool wrapped = Tool.publishToBlackboard(myTool);
// Capture specific typesTool wrapped = Tool.publishToBlackboard(myTool, SearchResult.class);
// With filtering and transformationTool wrapped = Tool.publishToBlackboard( myTool, SearchResult.class, result -> result.getScore() > 0.5, // filter result -> result.getDocument() // transform);
// Capture to a custom sinkTool wrapped = Tool.sinkArtifacts(myTool, SearchResult.class, mySink);// Capture all artifacts and publish to blackboardval wrapped = Tool.publishToBlackboard(myTool)
// Capture specific typesval wrapped = Tool.publishToBlackboard(myTool, SearchResult::class.java)
// With filtering and transformationval wrapped = Tool.publishToBlackboard( myTool, SearchResult::class.java, { it.score > 0.5 }, // filter { it.document } // transform)
// Capture to a custom sinkval wrapped = Tool.sinkArtifacts(myTool, SearchResult::class.java, mySink)Built-in Sinks
Section titled “Built-in Sinks”Embabel provides several ArtifactSink implementations:
| Sink | Purpose |
| --- | --- |
| BlackboardSink | Publishes to the current AgentProcess blackboard, making artifacts available to other actions |
| ListSink | Collects artifacts into a list, useful for aggregating results |
| CompositeSink | Delegates to multiple sinks, enabling multi-destination publishing |
Creating Custom Sinks
Section titled “Creating Custom Sinks”Implement ArtifactSink to create custom destinations:
// Publish to an event streamArtifactSink eventSink = artifact -> { eventPublisher.publish(new ToolArtifactEvent(artifact));};
// Use with any toolTool wrapped = Tool.sinkArtifacts(myTool, MyType.class, eventSink);// Publish to an event streamval eventSink = ArtifactSink { artifact -> eventPublisher.publish(ToolArtifactEvent(artifact))}
// Use with any toolval wrapped = Tool.sinkArtifacts(myTool, MyType::class.java, eventSink)How Decoration Enables Extension
Section titled “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:
- Wraps agent execution in a
Tool.call()method - Shares the parent blackboard with the child process
- 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
Section titled “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
Section titled “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 instanceSubagent.ofInstance(resolvedAgent).consuming(ConcertPlan.class)
// From an instance of an @Agent annotated class (e.g., a Spring bean)Subagent.ofAnnotatedInstance(myAgentBean).consuming(ConcertPlan.class)// From an @Agent annotated class with reified types (cleanest)Subagent.ofClass<ConcertAssembler>().consuming<ConcertPlan>()
// From a Java classSubagent.ofClass(ConcertAssembler::class.java).consuming(ConcertPlan::class.java)
// From a KClassSubagent.ofClass(ConcertAssembler::class).consuming(ConcertPlan::class)
// By agent name (resolved at runtime from platform)Subagent.byName("ConcertAssembler").consuming<ConcertPlan>()
// From an already-resolved Agent instanceSubagent.ofInstance(resolvedAgent).consuming<ConcertPlan>()
// From an instance of an @Agent annotated class (e.g., a Spring bean)Subagent.ofAnnotatedInstance(myAgentBean).consuming<ConcertPlan>()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
Section titled “Using Subagents with PromptRunner”Use withTool() to add a Subagent to your prompt:
@Actionpublic Concert assembleConcert(ConcertPlan plan, OperationContext context) { return context.ai() .withDefaultLlm() .withTool(Subagent.ofClass(PerformanceFinder.class) .consuming(WorksToFind.class)) (1) .creating(Concert.class) .fromPrompt("Assemble a concert based on: " + plan);}@Actionfun assembleConcert(plan: ConcertPlan, context: OperationContext): Concert { return context.ai() .withDefaultLlm() .withTool(Subagent.ofClass<PerformanceFinder>() .consuming<WorksToFind>()) (1) .creating(Concert::class.java) .fromPrompt("Assemble a concert based on: $plan")}- The LLM can now invoke
PerformanceFinderas a tool, providingWorksToFindinput to delegate the performance search task.
Subagent with Asset Tracking
Section titled “Subagent with Asset Tracking”For chat applications that track assets, wrap the Subagent with AssetAddingTool to automatically track returned artifacts:
@Actionpublic Concert assembleConcert(ConcertPlan plan, OperationContext context) { var subagent = Subagent.ofClass(PerformanceFinder.class) .consuming(WorksToFind.class); var trackedSubagent = assetTracker.addReturnedAssets(subagent); (1)
return context.ai() .withDefaultLlm() .withTool(trackedSubagent) .creating(Concert.class) .fromPrompt("Assemble a concert based on: " + plan);}
// With filtering - only track certain assetsvar trackedSubagent = assetTracker.addReturnedAssets(subagent, asset -> asset instanceof Performance // Only track Performance assets);@Actionfun assembleConcert(plan: ConcertPlan, context: OperationContext): Concert { val subagent = Subagent.ofClass<PerformanceFinder>() .consuming<WorksToFind>() val trackedSubagent = assetTracker.addReturnedAssets(subagent) (1)
return context.ai() .withDefaultLlm() .withTool(trackedSubagent) .creating(Concert::class.java) .fromPrompt("Assemble a concert based on: $plan")}
// With filtering - only track certain assetsval trackedSubagent = assetTracker.addReturnedAssets(subagent) { asset -> asset is Performance // Only track Performance assets}- Wrap with
addReturnedAssets()to track artifacts returned by the subagent.
Input Type and JSON Schema
Section titled “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 typepublic record WorksToFind(List<String> composers, String era, int maxResults) {}
// Create the subagent with explicit input typeSubagent.ofClass(PerformanceFinder.class).consuming(WorksToFind.class)// The input typedata class WorksToFind(val composers: List<String>, val era: String, val maxResults: Int)
// Create the subagent with explicit input typeSubagent.ofClass<PerformanceFinder>().consuming<WorksToFind>()The Subagent tool will:
- Use “PerformanceFinder” as the tool name (from
@Agentannotation) - Use “Finds performances” as the tool description (from
@Agentannotation) - 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
Section titled “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
Section titled “When to Use Subagent”| Scenario | Recommendation |
| --- | --- |
| Complex specialized task that has its own multi-action workflow | Use Subagent - the target agent can plan and execute multiple steps |
| Simple tool call with deterministic logic | Use a regular @LlmTool method instead |
| LLM-orchestrated mini-workflow with sub-tools | Consider AgenticTool which operates at the tool level |
| Need the full power of GOAP planning for the subtask | Subagent is ideal - the target agent uses its own planner |
Agentic Tools
Section titled “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
Section titled “Choosing an Agentic Tool”| Tool Type | Tool Availability | Best For | Example Use Case |
| --- | --- | --- | --- |
| SimpleAgenticTool | All tools available immediately | Simple orchestration, exploration tasks | Math calculator with add/multiply/divide tools |
| PlaybookTool | Progressive unlock via conditions (prerequisites, artifacts, blackboard) | Structured workflows, guided processes | Research workflow: search → analyze → summarize |
| StateMachineTool | State-based availability using enum states | Formal state machines, multi-phase processes | Order 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);}interface AgenticTool<THIS : AgenticTool<THIS>> : Tool { val llm: LlmOptions // LLM configuration val maxIterations: Int // Max tool loop iterations (default: 20)
fun withLlm(llm: LlmOptions): THIS fun withSystemPrompt(prompt: String): THIS fun withSystemPrompt(creator: AgenticSystemPromptCreator): THIS // Dynamic prompt fun withMaxIterations(maxIterations: Int): THIS fun withParameter(parameter: Tool.Parameter): THIS fun withToolObject(toolObject: Any): THIS}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);tool.withSystemPrompt { ctx, input -> "Context: ${ctx.processContext.processOptions.contextId}" + ". Task: $input"}SimpleAgenticTool: Flat Tool Orchestration
Section titled “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 toolSimpleAgenticTool 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 toolcontext.ai() .withDefaultLlm() .withTool(mathOrchestrator) .generateText("What is 5 + 3 * 2?");import com.embabel.agent.api.tool.agentic.simple.SimpleAgenticTool
val mathOrchestrator = SimpleAgenticTool("math-orchestrator", "Orchestrates math operations") .withTools(addTool, multiplyTool, divideTool) .withParameter(Tool.Parameter.string("expression", "Math expression to evaluate")) .withLlm(LlmOptions(model = "gpt-4"))
context.ai() .withDefaultLlm() .withTool(mathOrchestrator) .generateText("What is 5 + 3 * 2?")PlaybookTool: Conditional Tool Unlocking
Section titled “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 progressivelyPlaybookTool 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)import com.embabel.agent.api.tool.agentic.playbook.PlaybookTool
// Kotlin supports curried syntaxval researcher = PlaybookTool("researcher", "Research and analyze topics") .withTools(searchTool, fetchTool) // Always available .withTool(analyzeTool)(searchTool) // Curried: unlocks after search .withTool(summarizeTool)(analyzeTool) // Curried: unlocks after analyze .withParameter(Tool.Parameter.string("topic", "Research topic"))
// Or use fluent syntax (same as Java) .withTool(reportTool).unlockedByAll(searchTool, analyzeTool) .withTool(formatTool).unlockedByArtifact(Document::class) .withTool(actionTool).unlockedByBlackboard(UserProfile::class) .withTool(finalizeTool).unlockedWhen { ctx -> ctx.iterationCount >= 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
Section titled “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"));import com.embabel.agent.api.tool.agentic.state.StateMachineTool
enum class OrderState { DRAFT, CONFIRMED, SHIPPED, DELIVERED }
val orderProcessor = StateMachineTool("orderProcessor", "Process orders", OrderState::class.java) .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 confirmedTool resumedProcessor = orderProcessor.startingIn(OrderState.CONFIRMED);// Resume an order that's already confirmedval resumedProcessor = orderProcessor.startingIn(OrderState.CONFIRMED)Domain Tools: Tools from Retrieved Objects
Section titled “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 methodspublic 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 retrievedPlaybookTool userManager = new PlaybookTool("userManager", "Manage users") .withTools(searchUserTool, getUserTool) .withToolChainingFrom(User.class); // User methods available after getUserTool returns a single User// Domain class with @LlmTool methodsclass User(val id: String, val name: String) { @LlmTool(description = "Get user's profile information") fun getProfile(): String = "Profile for $name"
@LlmTool(description = "Update user's settings") fun updateSettings(settings: String): String = "Settings updated for $name"}
// Register domain tools - they become available when a single User is retrievedval userManager = PlaybookTool("userManager", "Manage users") .withTools(searchUserTool, getUserTool) .withToolChainingFrom<User>() // User methods available after getUserTool returns a single UserDomain 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
Section titled “Creating Agentic Tools”Create agentic tools using the constructor and fluent with* methods:
// Create sub-toolsTool 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 toolSimpleAgenticTool 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 toolcontext.ai() .withDefaultLlm() .withTool(mathOrchestrator) .generateText("What is 5 + 3 * 2?");// Create sub-toolsval addTool = Tool.of("add", "Adds two numbers") { input -> // Parse JSON input and compute result Tool.Result.text("5")}
val multiplyTool = Tool.of("multiply", "Multiplies two numbers") { input -> Tool.Result.text("6")}
// Create the agentic toolval mathOrchestrator = SimpleAgenticTool("math-orchestrator", "Orchestrates math operations") .withTools(addTool, multiplyTool) .withLlm(LlmOptions(model = "gpt-4")) .withSystemPrompt("Use the available tools to solve the given math problem")
// Use it like any other toolcontext.ai() .withDefaultLlm() .withTool(mathOrchestrator) .generateText("What is 5 + 3 * 2?")Defining Input Parameters
Section titled “Defining Input Parameters”Use the withParameter method with Tool.Parameter factory methods for concise parameter definitions:
// Research tool that requires a topic parameterSimpleAgenticTool 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 parametersSimpleAgenticTool 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());// Research tool that requires a topic parameterval researcher = SimpleAgenticTool("researcher", "Research a topic thoroughly") .withParameter(Tool.Parameter.string("topic", "The topic to research")) .withToolObjects(SearchTools(), SummarizerTools())
// Calculator with multiple parametersval calculator = SimpleAgenticTool("smart-calculator", "Perform complex calculations") .withParameter(Tool.Parameter.string("expression", "Mathematical expression to evaluate")) .withParameter(Tool.Parameter.integer("precision", "Decimal places for result", required = false)) // optional .withToolObject(MathTools())Available parameter factory methods:
Tool.Parameter.string(name, description, required?)- String parameterTool.Parameter.integer(name, description, required?)- Integer parameterTool.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
Section titled “Creating Agentic Tools from Annotated Objects”Use withToolObject or withToolObjects to add tools from objects with @LlmTool-annotated methods:
// Tool classes with @LlmTool methodspublic 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 descriptionSimpleAgenticTool assistant = new SimpleAgenticTool("assistant", "Multi-capability assistant") .withToolObjects(new SearchTools(), new CalculatorTools());
// With LLM options and custom system promptSimpleAgenticTool smartAssistant = new SimpleAgenticTool("smart-assistant", "Smart assistant") .withToolObjects(new SearchTools(), new CalculatorTools()) .withLlm(LlmOptions.withModel("gpt-4")) .withSystemPrompt("Use tools intelligently");// Tool classes with @LlmTool methodsclass SearchTools { @LlmTool(description = "Search the web") fun search(query: String): String = "Results for: $query"}
class CalculatorTools { @LlmTool(description = "Add two numbers") fun add(a: Int, b: Int): Int = a + b
@LlmTool(description = "Multiply two numbers") fun multiply(a: Int, b: Int): Int = a * b}
// Create agentic tool with tools from multiple objects// Uses default system prompt based on descriptionval assistant = SimpleAgenticTool("assistant", "Multi-capability assistant") .withToolObjects(SearchTools(), CalculatorTools())
// With LLM options and custom system promptval smartAssistant = SimpleAgenticTool("smart-assistant", "Smart assistant") .withToolObjects(SearchTools(), CalculatorTools()) .withLlm(LlmOptions(model = "gpt-4")) .withSystemPrompt("Use tools intelligently")Objects without @LlmTool methods are silently ignored, allowing you to mix objects safely.
Agentic Tools with Spring Dependency Injection
Section titled “Agentic Tools with Spring Dependency Injection”Agentic tools can encapsulate stateful services via dependency injection:
@Componentpublic 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@Configurationpublic 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 }}@Componentclass ResearchOrchestrator( private val webSearchService: WebSearchService, private val summarizerService: SummarizerService,) { @LlmTool(description = "Search the web for information") fun search(query: String): List<SearchResult> = webSearchService.search(query)
@LlmTool(description = "Summarize text content") fun summarize(content: String): String = summarizerService.summarize(content)}
// In your configuration@Configurationclass ToolConfiguration {
@Bean fun researchTool(orchestrator: ResearchOrchestrator): SimpleAgenticTool = SimpleAgenticTool("research-assistant", "Research topics using web search and summarization") .withToolObject(orchestrator) .withLlm(LlmOptions(role = "smart")) // Uses default system prompt based on description}How Agentic Tools Execute
Section titled “How Agentic Tools Execute”When an agentic tool’s call() method is invoked:
- The tool retrieves the current
AgentProcesscontext - It configures a
PromptRunnerwith the specifiedLlmOptions - It adds all sub-tools to the prompt runner
- It executes the prompt with the input, allowing the LLM to orchestrate the sub-tools
- The final LLM response is returned as the tool result
This means agentic tools create a nested LLM interaction: the outer LLM decides to call the agentic tool, then the inner LLM orchestrates the sub-tools.
Modifying Agentic Tools
Section titled “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 modificationsSimpleAgenticTool withNewLlm = base.withLlm(new LlmOptions().withModel("gpt-4"));SimpleAgenticTool withMoreTools = base.withTools(tool2, tool3);SimpleAgenticTool withNewPrompt = base.withSystemPrompt("Updated prompt");
// Add input parametersSimpleAgenticTool withParams = base.withParameter(Tool.Parameter.string("query", "Search query"));
// Add tools from an object with @LlmTool methodsSimpleAgenticTool withAnnotatedTools = base.withToolObject(calculatorService);
// Add tools from multiple objectsSimpleAgenticTool withMultipleObjects = base.withToolObjects(searchService, calculatorService);
// Dynamic system prompt based on execution context and inputSimpleAgenticTool withDynamicPrompt = base.withSystemPrompt((ctx, input) -> { String contextId = ctx.getProcessContext().getProcessOptions().getContextId().getId(); return "Process requests for context " + contextId + ". Task: " + input;});val base = SimpleAgenticTool("base", "Base orchestrator") .withTools(tool1) .withSystemPrompt("Original prompt")
// Create copies with modificationsval withNewLlm = base.withLlm(LlmOptions(model = "gpt-4"))val withMoreTools = base.withTools(tool2, tool3)val withNewPrompt = base.withSystemPrompt("Updated prompt")
// Add input parametersval withParams = base.withParameter(Tool.Parameter.string("query", "Search query"))
// Add tools from an object with @LlmTool methodsval withAnnotatedTools = base.withToolObject(calculatorService)
// Add tools from multiple objectsval withMultipleObjects = base.withToolObjects(searchService, calculatorService)
// Dynamic system prompt based on execution context and inputval withDynamicPrompt = base.withSystemPrompt { ctx, input -> val contextId = ctx.processContext.processOptions.contextId?.id "Process requests for context $contextId. Task: $input"}The available modification methods are:
withParameter(Tool.Parameter): Add an input parameter (useTool.Parameter.string(),.integer(),.double())withLlm(LlmOptions): Set LLM configurationwithTools(vararg Tool): Add additional Tool instanceswithToolObject(Any): Add tools from an object with@LlmToolmethodswithToolObjects(vararg Any): Add tools from multiple annotated objectswithSystemPrompt(String): Set a fixed system promptwithSystemPrompt((ExecutingOperationContext, String) → String): Set a dynamic prompt based on execution context and inputwithCaptureNestedArtifacts(Boolean): Control whether artifacts from nested agentic tool calls are captured (default:false)withToolChainingFrom(Class<T>): Register a class whose@LlmToolmethods become available when an artifact of that type is returnedwithToolChainingFrom(Class<T>, DomainToolPredicate<T>): Register with a predicate to filter which instances contribute toolswithToolChainingFromAny(): Auto-discover tools from any returned artifact with@LlmToolmethods
Controlling Artifact Capture in Nested Agentic Tools
Section titled “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 capturedSimpleAgenticTool concertAssembler = new SimpleAgenticTool("assembleConcert", "Assemble a concert program") .withTools(findPerformancesTool, createConcertTool);// Only the Concert artifact from createConcert is returned
// Opt-in: capture all nested artifactsSimpleAgenticTool fullCapture = concertAssembler.withCaptureNestedArtifacts(true);// Both Performance artifacts from findPerformances AND Concert from createConcert are captured// Default: nested artifacts are NOT capturedval concertAssembler = SimpleAgenticTool("assembleConcert", "Assemble a concert program") .withTools(findPerformancesTool, createConcertTool)// Only the Concert artifact from createConcert is returned
// Opt-in: capture all nested artifactsval fullCapture = concertAssembler.withCaptureNestedArtifacts(true)// Both Performance artifacts from findPerformances AND Concert from createConcert are capturedTool Chaining
Section titled “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 methodspublic 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 chainingSimpleAgenticTool 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")// Domain class with tool methodsclass User(val id: String, var email: String) { @LlmTool("Update the user's email address") fun updateEmail(newEmail: String): String { this.email = newEmail return "Email updated to $newEmail" }}
// Create agentic tool with tool chainingval userManager = SimpleAgenticTool("userManager", "Manage user accounts") .withTools(searchUserTool, getUserTool) // Tools to find/retrieve users .withToolChainingFrom<User>() // 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
Section titled “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 usersSimpleAgenticTool 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// Only expose tools for admin usersval adminManager = SimpleAgenticTool("adminManager", "Manage admin users") .withTools(searchUserTool, getUserTool) .withToolChainingFrom<User> { user, _ -> user.role == "admin" }
// Regular users won't have their tools exposed// Only when an admin User is retrieved will updateEmail() become availableAuto-Discovery Mode
Section titled “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 objectSimpleAgenticTool 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// Auto-discover tools from any returned objectval explorer = 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 activeThis 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 returnedPlaybookTool: Chained tools are available immediately (not subject to unlock conditions)StateMachineTool: Chained tools are available globally (not state-bound)
Tool Chaining on PromptRunner
Section titled “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 callString result = ai.generateText("Find user Alice and update her email to alice@new.com");// In an @Action method:val ai = context.ai() .withToolChainingFrom<User>() // Chained tools from User .withTools(searchUserTool, getUserTool)
// When getUserTool returns a User artifact, User's @LlmTool methods// automatically become available for the LLM to callval result = ai.generateText("Find user Alice and update her email to alice@new.com")Filtering Artifacts for Asset Tracking
Section titled “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 filterTool 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 toolsList<Tool> wrappedTools = assetTracker.addAnyReturnedAssets( List.of(tool1, tool2, tool3), asset -> asset.getId().startsWith("important-"));// Track only assets that pass the filterval wrapped = assetTracker.addReturnedAssets(concertTool) { asset -> // Only track concerts with at least 3 works asset is Concert && asset.works.size >= 3}
// Apply the same filter to multiple toolsval wrappedTools = assetTracker.addAnyReturnedAssets( listOf(tool1, tool2, tool3)) { asset -> asset.id.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
Section titled “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:
| Framework | Pattern | Embabel Equivalent |
| --- | --- | --- |
| LangChain/LangGraph | Supervisor agent with worker agents | SimpleAgenticTool with sub-tools |
| Google ADK | Coordinator with sub_agents / AgentTool | SimpleAgenticTool with sub-tools |
The key differences:
- Tool-centric: Embabel’s agentic tools operate at the tool level, not the agent level. They’re lightweight and can be mixed freely with regular tools.
- Simpler model: No graph-based workflows or explicit Sequential/Parallel/Loop patterns—just LLM-driven orchestration.
- Composable: An agentic tool is still “just a tool” that can be used anywhere tools are accepted.
However, for anything beyond simple orchestration, Embabel offers far more powerful alternatives:
| Scenario | Use This Instead | | --- | --- | | Business processes with defined outputs | GOAP planner - deterministic, goal-oriented planning with preconditions and effects | | Exploration and event-driven systems | Utility AI - selects highest-value action at each step | | Branching, looping, or stateful workflows | @State workflows - typesafe state machines with GOAP planning within each state |
These provide deterministic, typesafe planning that is far more predictable and powerful than supervisor-style LLM orchestration.
Use 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.
Progressive Tools
Section titled “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
Section titled “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. ItsinnerTools(process: AgentProcess)method returns tools that may vary depending on the current agent process state.UnfoldingTool: AProgressiveToolwith 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
Section titled “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
Section titled “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 toolsTool 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 facadevar databaseTool = UnfoldingTool.of( "database_operations", "Use this tool to work with the database. Invoke to see specific operations.", List.of(queryTool, insertTool, deleteTool));import com.embabel.agent.api.tool.progressive.UnfoldingToolimport com.embabel.agent.api.tool.Tool
// Create inner toolsval queryTool = Tool.of( name = "query_table", description = "Execute a SQL query", inputSchema = Tool.InputSchema.of(Tool.Parameter.string("sql", "The SQL query to execute"))) { input -> Tool.Result.text("""{"rows": 5}""") }
val insertTool = Tool.of( name = "insert_record", description = "Insert a new record", inputSchema = Tool.InputSchema.of(Tool.Parameter.string("table", "Table name"))) { input -> Tool.Result.text("""{"id": 123}""") }
val deleteTool = Tool.of( name = "delete_record", description = "Delete a record", inputSchema = Tool.InputSchema.of(Tool.Parameter.integer("id", "Record ID to delete"))) { input -> Tool.Result.text("""{"deleted": true}""") }
// Create the UnfoldingTool facadeval databaseTool = UnfoldingTool.of( name = "database_operations", description = "Use this tool to work with the database. Invoke to see specific operations.", innerTools = listOf(queryTool, insertTool, deleteTool))Fluent Builder API
Section titled “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 morevar 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 sourcesimport com.embabel.agent.api.tool.progressive.UnfoldingTool
// Start with base tools and add moreval combinedTools = UnfoldingTool.of( name = "workspace", description = "Workspace operations. Invoke to see available tools.", innerTools = listOf(baseTool)) .withTools(searchTool, filterTool) // Add individual tools .withToolObject(DatabaseOperations()) // Add from @LlmTool class .withToolObject(FileOperations()) // Chain multiple sourcesThis 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
Section titled “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 categoryMap<String, List<Tool>> toolsByCategory = Map.of( "read", List.of(readFileTool, listDirectoryTool, searchFilesTool), "write", List.of(writeFileTool, deleteFileTool, moveFileTool));
// Create category-based UnfoldingToolvar 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 exposedimport com.embabel.agent.api.tool.progressive.UnfoldingTool
// Define tools by categoryval toolsByCategory = mapOf( "read" to listOf(readFileTool, listDirectoryTool, searchFilesTool), "write" to listOf(writeFileTool, deleteFileTool, moveFileTool))
// Create category-based UnfoldingToolval fileTool = UnfoldingTool.byCategory( name = "file_operations", description = "File operations. Pass category: 'read' for reading files, 'write' for modifying files.", toolsByCategory = 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 exposedCustom Selection Logic
Section titled “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); } });import com.embabel.agent.api.tool.progressive.UnfoldingToolimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
val allTools = listOf(basicTool, advancedTool, adminTool)
val permissionBasedTool = UnfoldingTool.selectable( name = "api_operations", description = "API operations. Pass 'accessLevel': 'basic', 'advanced', or 'admin'.", innerTools = allTools, inputSchema = Tool.InputSchema.of( Tool.Parameter.string("accessLevel", "Access level for operations", required = true, enumValues = listOf("basic", "advanced", "admin")) ),) { input -> // Custom selection logic val mapper = jacksonObjectMapper() val params = mapper.readValue(input, Map::class.java) when (params["accessLevel"]) { "basic" -> listOf(basicTool) "advanced" -> listOf(basicTool, advancedTool) "admin" -> allTools else -> listOf(basicTool) }}Guide Tool Behavior
Section titled “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
Section titled “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 strategiesChainedToolInjectionStrategy combined = ChainedToolInjectionStrategy.withUnfolding(customStrategy1, customStrategy2);import com.embabel.agent.spi.loop.ChainedToolInjectionStrategy
// Combine UnfoldingTool support with custom strategiesval combined = ChainedToolInjectionStrategy.withUnfolding(customStrategy1, customStrategy2)How UnfoldingToolWorks
Section titled “How UnfoldingToolWorks”- Initial state: The LLM sees only the facade tool (e.g., “database_operations”)
- LLM invokes: The LLM calls the facade with optional arguments
- Strategy evaluates:
UnfoldingToolInjectionStrategydetects the invocation - Tools replaced: The facade is replaced by a guide tool and inner tools are added
- 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
Section titled “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.");val spotifySearch = UnfoldingTool.of( name = "spotify_search", description = "Search Spotify for music data including artists, albums, and tracks.", innerTools = listOf(vectorSearchTool, textSearchTool, regexSearchTool), childToolUsageNotes = """ 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. """.trimIndent())After the LLM invokes spotify_search, it will see:
vector_search- the actual search tooltext_search- the actual search toolregex_search- the actual search toolspotify_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
Section titled “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);val personalityTool = UnfoldingTool.of( name = "change_personality", description = "Change the assistant's personality. Invoke to see personality options.", innerTools = listOf(formalTool, casualTool, technicalTool), exclusive = true, // 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
Section titled “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 classvar tool = UnfoldingTool.fromInstance(new DatabaseTools());import com.embabel.agent.api.annotation.UnfoldingToolsimport com.embabel.agent.api.annotation.LlmTool
@UnfoldingTools( name = "database_operations", description = "Database operations. Invoke to see specific tools.")class DatabaseTools {
@LlmTool(description = "Execute a SQL query") fun query(sql: String): QueryResult { // implementation }
@LlmTool(description = "Insert a record") fun insert(table: String, data: Map<String, Any>): InsertResult { // implementation }
@LlmTool(description = "Delete a record") fun delete(id: Long) { // implementation }}
// Create the UnfoldingTool from the annotated classval tool = UnfoldingTool.fromInstance(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 }}@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.")class MusicSearchTools {
@LlmTool(description = "Semantic search using embeddings") fun vectorSearch(query: String): List<Track> { // implementation }
@LlmTool(description = "Exact match text search") fun textSearch(query: String): List<Track> { // implementation }}Category-Based Selection with Annotations
Section titled “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 selectionvar 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( name = "file_operations", description = "File operations. Pass category: 'read' or 'write'.")class FileTools {
@LlmTool(description = "Read file contents", category = "read") fun readFile(path: String): String = File(path).readText()
@LlmTool(description = "List directory contents", category = "read") fun listDir(path: String): List<String> = File(path).list()?.toList() ?: emptyList()
@LlmTool(description = "Write file contents", category = "write") fun writeFile(path: String, content: String) { File(path).writeText(content) }
@LlmTool(description = "Delete a file", category = "write") fun deleteFile(path: String) { File(path).delete() }}
// Automatically creates category-based selectionval tool = UnfoldingTool.fromInstance(FileTools())// When invoked with {"category": "read"}, only read tools are exposed// When invoked with {"category": "write"}, only write tools are exposed@UnfoldingTools Annotation Attributes
Section titled “@UnfoldingTools Annotation Attributes”| Attribute | Type | Default | Description |
| --- | --- | --- | --- |
| name | String | Required | Name of the facade tool the LLM will see |
| description | String | Required | Description explaining the tool category |
| removeOnInvoke (deprecated) | boolean | true (ignored — always replaced by guide tool) | Whether to remove the facade after invocation |
| categoryParameter | String | "category" | Name of the parameter for category selection |
@LlmTool Category Attribute
Section titled “@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
Section titled “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}@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.")data class SpotifyTools(val user: ImpromptuUser, val spotifyService: SpotifyService) {
@LlmTool(description = "Check if user has linked their Spotify account") fun checkSpotifyStatus(): String { /* ... */ }
@LlmTool(description = "Get the user's Spotify playlists") fun getPlaylists(): String { /* ... */ }
@LlmTool(description = "Search for tracks on Spotify by song name, artist, or both") fun searchTracks(query: String): String { /* ... */ }
@LlmTool(description = "Play a track on Spotify by searching for it") fun playTrack(query: String): String { /* ... */ }
@LlmTool(description = "Pause the current Spotify playback") fun pausePlayback(): String { /* ... */ }
// ... more tools}With this setup:
- The LLM initially sees a single
spotifytool - When the user says “play some jazz”, the LLM invokes
spotify - The
spotifyfacade is replaced with all the inner tools (getPlaylists,searchTracks,playTrack, etc.) - The LLM can then call
searchTracksorplayTrackto fulfill the request
Auto-Detection with Tool.fromInstance()
Section titled “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 UnfoldingToolList<Tool> tools = Tool.fromInstance(new SpotifyTools(user, service));// Returns a single UnfoldingTool, not individual tools// Auto-detects @UnfoldingTools and creates an UnfoldingToolval tools = Tool.fromInstance(SpotifyTools(user, service))// Returns a single UnfoldingTool, not individual toolsThis 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 facadecontext.ai() .withToolObject(SpotifyTools(user, spotifyService)) .respond("Play some classical music")// The SpotifyTools are automatically exposed as a single UnfoldingTool facadeWrapping Tool Objects with fromToolObject()
Section titled “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 @UnfoldingToolsFileWriteTools fileTools = new FileWriteToolsImpl(workspace);
var tool = UnfoldingTool.fromToolObject( fileTools, "file_write_tools", "Tools for writing and managing files. Invoke to see specific operations.");import com.embabel.agent.api.tool.progressive.UnfoldingTool
// FileWriteTools is an interface with @LlmTool default methods—// it cannot be annotated with @UnfoldingToolsval fileTools: FileWriteTools = FileWriteToolsImpl(workspace)
val tool = UnfoldingTool.fromToolObject( instance = fileTools, name = "file_write_tools", description = "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);val tool = UnfoldingTool.fromToolObject( instance = fileTools, name = "file_write_tools", description = "Tools for writing and managing files.", removeOnInvoke = false, childToolUsageNotes = "Use writeFile for new files, appendFile for existing ones.",)Nested UnfoldingTools
Section titled “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
Section titled “Programmatic Nesting”Use UnfoldingTool.of() to create nested hierarchies programmatically:
// Inner UnfoldingTool for user managementvar userManagement = UnfoldingTool.of( "user_management", "User management operations", List.of(createUserTool, deleteUserTool, updateUserTool));
// Inner UnfoldingTool for system configvar systemConfig = UnfoldingTool.of( "system_config", "System configuration operations", List.of(updateConfigTool, backupTool, restoreTool));
// Outer UnfoldingTool containing bothvar 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// Inner UnfoldingTool for user managementval userManagement = UnfoldingTool.of( name = "user_management", description = "User management operations", innerTools = listOf(createUserTool, deleteUserTool, updateUserTool))
// Inner UnfoldingTool for system configval systemConfig = UnfoldingTool.of( name = "system_config", description = "System configuration operations", innerTools = listOf(updateConfigTool, backupTool, restoreTool))
// Outer UnfoldingTool containing bothval adminTool = UnfoldingTool.of( name = "admin_operations", description = "Administrative operations. Invoke to see categories.", innerTools = listOf(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, updateUserAnnotation-Based Nesting with Inner Classes
Section titled “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 automaticallyvar 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@UnfoldingTools( name = "admin_operations", description = "Administrative operations. Invoke to access specific areas.")class AdminTools {
@LlmTool(description = "Get system status") fun getStatus(): String = "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." ) class UserManagement {
@LlmTool(description = "Create a new user") fun createUser(username: String): String = "Created user: $username"
@LlmTool(description = "Delete a user") fun deleteUser(username: String): String = "Deleted user: $username"
// Can nest even deeper @UnfoldingTools( name = "user_permissions", description = "User permission operations" ) class Permissions {
@LlmTool(description = "Grant permission to user") fun grant(user: String, permission: String): String = "Granted"
@LlmTool(description = "Revoke permission from user") fun revoke(user: String, permission: String): String = "Revoked" } }
@UnfoldingTools( name = "system_config", description = "System configuration. Invoke to see config tools." ) class SystemConfig {
@LlmTool(description = "Update configuration") fun updateConfig(key: String, value: String): String = "Updated"
@LlmTool(description = "Backup configuration") fun backup(): String = "Backed up" }}
// Create the full nested hierarchy automaticallyval adminTool = UnfoldingTool.fromInstance(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, revokeThis approach provides several benefits:
- Encapsulation: All related tools are organized in a single class hierarchy
- Automatic discovery: No manual wiring - inner classes with
@UnfoldingToolsare automatically included - Arbitrary depth: Nest as many levels as needed to organize your tools logically
- Mixed content: Each level can have both direct
@LlmToolmethods and nested@UnfoldingToolsclasses
Dynamically Configured Inner Tools
Section titled “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 parametervar 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// UnfoldingTool that configures database tools based on connection parameterval databaseTool = UnfoldingTool.selectable( name = "database", description = "Database operations. Pass 'connection' to configure tools.", innerTools = emptyList(), // Tools created dynamically inputSchema = Tool.InputSchema.of( Tool.Parameter.string("connection", "Database connection string") ),) { input -> // Parse connection from input val mapper = jacksonObjectMapper() val params = mapper.readValue(input, Map::class.java) val connection = params["connection"] as? String ?: "localhost"
// Create tools configured with the connection string listOf( Tool.of("query", "Query database at $connection") { queryInput -> // Tool has captured the connection string Tool.Result.text("Queried $connection: $queryInput") }, Tool.of("insert", "Insert into database at $connection") { insertInput -> Tool.Result.text("Inserted into $connection") } )}
// When LLM invokes with {"connection": "prod-db.example.com"}// The injected tools are configured to use that specific connectionThis 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
Section titled “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); }) ); });val cartTool = UnfoldingTool.selectable( name = "shopping_cart", description = "Shopping cart. Pass 'cart_id' to select which cart to operate on.", innerTools = emptyList(), inputSchema = Tool.InputSchema.of( Tool.Parameter.string("cart_id", "Shopping cart ID") ),) { input -> // Each invocation creates a fresh set of tools with shared state val cartId = parseCartId(input) val cartItems = mutableListOf<String>() // Shared state
listOf( Tool.of( name = "add_item", description = "Add item to cart $cartId", inputSchema = Tool.InputSchema.of(Tool.Parameter.string("item", "Item name")) ) { itemInput -> val item = parseItem(itemInput) cartItems.add(item) // Captured state Tool.Result.text("Added $item. Total: ${cartItems.size}") }, Tool.of("view_cart", "View cart $cartId contents") { _ -> Tool.Result.text("Cart $cartId: ${cartItems.joinToString(", ")}") }, Tool.of("checkout", "Checkout cart $cartId") { _ -> val total = calculateTotal(cartItems) cartItems.clear() Tool.Result.text("Checked out $cartId for $total") } )}Comparison with Other Approaches
Section titled “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: trueflag 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
ToolProviderinterface 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:
| Aspect | Other Approaches | UnfoldingTool | | --- | --- | --- | | Infrastructure | Requires vector stores, embeddings, search indices, or pre-filtering logic | No additional infrastructure required | | Selection Timing | Before LLM call (pre-filtering based on message analysis) | After LLM decides to invoke a facade (LLM-driven discovery) | | Latency | Search/embedding adds latency; two-pass selection doubles LLM calls | Instant unlock on invocation | | Scalability | Search quality degrades with very large tool sets; requires careful tuning | Scales to any number of tools via nesting without degradation | | Determinism | Search results can vary based on embedding similarity | Deterministic: invoking a facade always reveals the same tools | | Cost | Embedding generation, vector search, or extra LLM calls incur compute costs | No additional compute beyond the tool call itself | | Dynamic Behavior | Can only return references to pre-existing tools | Can 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
Section titled “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
Section titled “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 Name | Purpose |
| --- | --- |
| process_status | Current process ID, status, running time, and goal information |
| process_budget | Budget limits (cost, tokens, actions) and remaining capacity |
| process_cost | Total cost (LLM and embedding invocations), invocation counts, and detailed token usage |
| process_history | List of actions taken so far with execution times |
| process_tools_stats | Tool usage statistics (call counts per tool) |
| process_models | All 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 toolvar processTools = new AgentProcessTools().create();
// Add to SimpleAgenticToolvar assistant = new SimpleAgenticTool("assistant", "...") .withTools(processTools);import com.embabel.agent.tools.process.AgentProcessToolsimport com.embabel.agent.api.tool.progressive.UnfoldingTool
// Create the tool - typically added to an agentic toolval processTools = AgentProcessTools().create()
// Add to SimpleAgenticToolval assistant = SimpleAgenticTool("assistant", "...") .withTools(processTools)BlackboardTools: Accessing Workflow Data
Section titled “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
ArtifactSinkto 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 Name | Purpose |
| --- | --- |
| blackboard_list | List all objects in the blackboard with their types and indices |
| blackboard_get | Get an object by its binding name (e.g., “user”, “searchResults”) |
| blackboard_last | Get the most recent object of a given type (matches simple name or FQN) |
| blackboard_describe | Get a detailed description/formatting of an object by binding name |
| blackboard_count | Count 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 formattingvar blackboardTools = new BlackboardTools().create();
// Or with custom formatting for blackboard entriesvar blackboardTools = new BlackboardTools().create(myCustomFormatter);
// Add to SimpleAgenticToolvar assistant = new SimpleAgenticTool("assistant", "...") .withTools(blackboardTools);import com.embabel.agent.tools.blackboard.BlackboardToolsimport com.embabel.agent.api.tool.progressive.UnfoldingTool
// Create with default formattingval blackboardTools: UnfoldingTool= BlackboardTools().create()
// Or with custom formatting for blackboard entriesval blackboardTools = BlackboardTools().create(myCustomFormatter)
// Add to SimpleAgenticToolval assistant = SimpleAgenticTool("assistant", "...") .withTools(blackboardTools)Formatting blackboard entries:
By default, BlackboardTools uses DefaultBlackboardEntryFormatter which:
- Uses
infoString()for objects implementingHasInfoString - Uses
contentproperty for objects implementingHasContent - 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 namedPerson - 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
Section titled “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() );val awarenessAgent = SimpleAgenticTool( name = "aware_assistant", systemPrompt = "An assistant that can check its own status and access previous results").withTools( AgentProcessTools().create(), BlackboardTools().create())Process Communication Tools
Section titled “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.
| Tool | Purpose | Presentation |
| --- | --- | --- |
| progress | Report transient status updates during long-running work | Shown as a progress banner (ephemeral) |
| communicate | Send a permanent message to the user | Shown as an assistant chat bubble (persistent) |
ProgressTool
Section titled “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 SimpleAgenticToolvar assistant = new SimpleAgenticTool("assistant", "...") .withTools(progressTool);import com.embabel.agent.api.tool.ProgressToolimport com.embabel.agent.api.tool.Tool
val progressTool: Tool = ProgressTool.create()
// Add to a SimpleAgenticToolval assistant = 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
Section titled “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 SimpleAgenticToolvar assistant = new SimpleAgenticTool("assistant", "...") .withTools(communicateTool);import com.embabel.agent.api.tool.CommunicateToolimport com.embabel.agent.api.tool.Tool
val communicateTool: Tool = CommunicateTool.create()
// Add to a SimpleAgenticToolval assistant = 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
Section titled “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() );val assistant = SimpleAgenticTool( name = "assistant", systemPrompt = "An assistant that reports progress and communicates results").withTools( ProgressTool.create(), CommunicateTool.create())Just-in-Time Tool Group Initialization
Section titled “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: trueWith 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
Section titled “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
Section titled “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;
@Configurationpublic class ToolConfiguration {
@Bean public McpToolFactory mcpToolFactory(List<McpSyncClient> clients) { return new SpringAiMcpToolFactory(clients); }}import com.embabel.agent.tools.mcp.McpToolFactoryimport com.embabel.agent.spi.support.springai.SpringAiMcpToolFactoryimport io.modelcontextprotocol.client.McpSyncClient
@Configurationclass ToolConfiguration {
@Bean fun mcpToolFactory(clients: List<McpSyncClient>): McpToolFactory = SpringAiMcpToolFactory(clients)}Getting Individual MCP Tools
Section titled “Getting Individual MCP Tools”Use toolByName to retrieve a single MCP tool by its exact name:
// Returns null if not foundTool 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");// Returns null if not foundval braveSearch = mcpToolFactory.toolByName("brave_web_search")braveSearch?.let { tool -> ai.withTool(tool).generateText("Search for recent news about AI")}
// Throws IllegalArgumentException if not found (with helpful error message)val requiredTool = mcpToolFactory.requiredToolByName("brave_web_search")Creating UnfoldingToolFacades from MCP
Section titled “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 namesvar wikipediaTool = mcpToolFactory.unfoldingByName( "wikipedia", "Search and find content from Wikipedia", Set.of("search_wikipedia", "get_article", "get_related_topics", "get_summary"));// Create a UnfoldingTool with specific tool namesval wikipediaTool = mcpToolFactory.unfoldingByName( name = "wikipedia", description = "Search and find content from Wikipedia", toolNames = setOf("search_wikipedia", "get_article", "get_related_topics", "get_summary"))By Regex Patterns:
import java.util.regex.Pattern;
// Match tools by regex patternsvar dbTool = mcpToolFactory.unfoldingMatching( "database_operations", "Database operations. Invoke to access database tools.", List.of(Pattern.compile("^db_.*"), Pattern.compile(".*query.*")));// Match tools by regex patternsval dbTool = mcpToolFactory.unfoldingMatching( name = "database_operations", description = "Database operations. Invoke to access database tools.", patterns = listOf("^db_".toRegex(), "query.*".toRegex()))With Custom Filter:
// Custom filter predicatevar webTool = mcpToolFactory.unfolding( "web_operations", "Web operations. Invoke to access web tools.", callback -> callback.getToolDefinition().name().startsWith("web_"));// Custom filter predicateval webTool = mcpToolFactory.unfolding( name = "web_operations", description = "Web operations. Invoke to access web tools.", filter = { it.toolDefinition.name().startsWith("web_") })Controlling Facade Removal
Section titled “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 invocationvar persistentTool = mcpToolFactory.unfoldingByName( "wikipedia", "Search Wikipedia", Set.of("search_wikipedia", "get_article"), false // removeOnInvoke = false);// Keep facade even after invocationval persistentTool = mcpToolFactory.unfoldingByName( name = "wikipedia", description = "Search Wikipedia", toolNames = setOf("search_wikipedia", "get_article"), removeOnInvoke = false)Real-World Example: Chatbot with MCP Tools
Section titled “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:
@Configurationpublic 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); }}@Configurationclass ChatConfiguration {
@Bean fun mcpToolFactory(clients: List<McpSyncClient>): McpToolFactory = SpringAiMcpToolFactory(clients)
@Bean fun commonTools(mcpToolFactory: McpToolFactory): CommonTools { val deferMessage = "Use this tool only after trying local sources" val tools = mutableListOf<Any>()
// Single MCP tool - gracefully handle missing tools mcpToolFactory.toolByName("brave_web_search")?.let { braveSearch -> tools.add(braveSearch.withNote(deferMessage)) }
// UnfoldingTool grouping related Wikipedia MCP tools val wikipediaTool = mcpToolFactory.unfoldingByName( name = "wikipedia", description = "Search and find content from Wikipedia: $deferMessage", toolNames = setOf("search_wikipedia", "get_article", "get_related_topics", "get_summary") ) if (wikipediaTool.innerTools.isNotEmpty()) { tools.add(wikipediaTool) }
return 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
Section titled “McpToolFactory Method Summary”| Method | Description |
| --- | --- |
| 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. |