Invoking Embabel Agents
While many examples show Embabel agents being invoked via UserInput through the Embabel shell, they can also be invoked programmatically with strong typing.
This is usually how they’re used in web applications. It is also the most deterministic approach as code, rather than LLM assessment of user input, determines which agent is invoked and how.
Creating an AgentProcess Programmatically
You can create and execute agent processes directly using the AgentPlatform:
// Create an agent process with bindings
AgentProcess agentProcess = agentPlatform.createAgentProcess(
myAgent,
new ProcessOptions(),
Map.of("input", userRequest)
);
// Start the process and wait for completion
Object result = agentPlatform.start(agentProcess).get();
// Or run synchronously
AgentProcess completedProcess = agentProcess.run();
MyResultType result = completedProcess.last(MyResultType.class);
You can create processes and populate their input map from varargs objects:
// Create process from objects (like in web controllers)
AgentProcess agentProcess = agentPlatform.createAgentProcessFrom(
travelAgent,
new ProcessOptions(),
travelRequest,
userPreferences
);
Using AgentInvocation
AgentInvocation provides a higher-level, type-safe API for invoking agents.
It automatically finds the appropriate agent based on the expected result type.
Basic Usage
// Simple invocation with explicit result type
var invocation =
AgentInvocation.create(agentPlatform, TravelPlan.class);
TravelPlan plan = invocation.invoke(travelRequest);
Invocation with Named Inputs
// Invoke with a map of named inputs
Map<String, Object> inputs = Map.of(
"request", travelRequest,
"preferences", userPreferences
);
TravelPlan plan = invocation.invoke(inputs);
Custom Process Options
Configure verbosity, budget, and other execution options:
var processOptions = new ProcessOptions()
.withVerbosity(new Verbosity()
.withShowPrompts(true)
.withShowLlmResponses(true)
.withDebug(true));
var invocation =
AgentInvocation.builder(agentPlatform)
.options(processOptions)
.build(TravelPlan.class);
TravelPlan plan = invocation.invoke(travelRequest);
Passing Tool Call Context at Invocation Time
Use ProcessOptions.withToolCallContext() to attach out-of-band metadata that flows through the entire agent run to every tool invoked — including remote MCP tools, where it becomes MCP _meta on the wire.
This is the right place for cross-cutting infrastructure concerns such as auth tokens, tenant IDs, and correlation IDs that come from the incoming request.
// In a REST controller or event handler
var processOptions = new ProcessOptions()
.withToolCallContext(Map.of(
"authToken", request.getHeader("Authorization"),
"tenantId", request.getHeader("X-Tenant-Id"),
"correlationId", UUID.randomUUID().toString()
));
var invocation = AgentInvocation.builder(agentPlatform)
.options(processOptions)
.build(CustomerReport.class);
CustomerReport report = invocation.invoke(customerQuery);
Context set here can be read by any @LlmTool method that declares a ToolCallContext parameter.
It can also be supplemented per-interaction inside @Action methods using PromptRunner.withToolCallContext(); interaction-level values win on conflict.
See /reference/tools#tool-call-context for the full context pipeline.
Asynchronous Invocation
For long-running operations, use async invocation:
CompletableFuture<TravelPlan> future = invocation.invokeAsync(travelRequest);
// Handle result when complete
future.thenAccept(plan -> {
logger.info("Travel plan generated: {}", plan);
});
// Or wait for completion
TravelPlan plan = future.get();
Agent Selection
AgentInvocation automatically finds agents by examining their goals:
- Searches all registered agents in the platform
- Finds agents with goals that produce the requested result type
- Uses the first matching agent found
- Throws an error if no suitable agent is available
Real-World Web Application Example
Here’s how AgentInvocation is used in the Tripper travel planning application with htmx for asynchronous UI updates:
@Controller
public class TripPlanningController {
private final AgentPlatform agentPlatform;
private final ConcurrentHashMap<String, CompletableFuture<TripPlan>> activeJobs =
new ConcurrentHashMap<>();
private static final Logger logger =
LoggerFactory.getLogger(TripPlanningController.class);
private static final ConcurrentHashMap<String, TripPlan> tripResultCache =
new ConcurrentHashMap<>();
public TripPlanningController(AgentPlatform agentPlatform) {
this.agentPlatform = agentPlatform;
}
@PostMapping("/plan-trip")
public String planTrip(
@ModelAttribute TripRequest tripRequest,
Model model) {
// Generate unique job ID for tracking
String jobId = UUID.randomUUID().toString();
// Create agent invocation with custom options
var processOptions = new ProcessOptions()
.withVerbosity(new Verbosity().withShowPrompts(true));
var invocation = AgentInvocation.builder(agentPlatform)
.options(processOptions)
.build(TripPlan.class);
// Start async agent execution
CompletableFuture<TripPlan> future = invocation.invokeAsync(tripRequest);
activeJobs.put(jobId, future);
// Set up completion handler
future.whenComplete((result, throwable) -> {
if (throwable != null) {
logger.error("Trip planning failed for job {}", jobId, throwable);
} else {
logger.info("Trip planning completed for job {}", jobId);
}
});
model.addAttribute("jobId", jobId);
model.addAttribute("tripRequest", tripRequest);
// Return htmx template that will poll for results
return "trip-planning-progress";
}
@GetMapping("/trip-status/\{jobId}")
@ResponseBody
public ResponseEntity<Map<String, Object>> getTripStatus(@PathVariable String jobId) {
CompletableFuture<TripPlan> future = activeJobs.get(jobId);
if (future == null) {
return ResponseEntity.notFound().build();
}
if (future.isDone()) {
try {
TripPlan tripPlan = future.get();
activeJobs.remove(jobId);
return ResponseEntity.ok(Map.of(
"status", "completed",
"result", tripPlan,
"redirect", "/trip-result/" + jobId
));
} catch (Exception e) {
activeJobs.remove(jobId);
return ResponseEntity.ok(Map.of(
"status", "failed",
"error", e.getMessage()
));
}
} else if (future.isCancelled()) {
activeJobs.remove(jobId);
return ResponseEntity.ok(Map.of("status", "cancelled"));
} else {
return ResponseEntity.ok(Map.of(
"status", "in_progress",
"message", "Planning your amazing trip..."
));
}
}
@GetMapping("/trip-result/\{jobId}")
public String showTripResult(
@PathVariable String jobId,
Model model) {
// Retrieve completed result from cache or database
TripPlan tripPlan = tripResultCache.get(jobId);
if (tripPlan == null) {
return "redirect:/error";
}
model.addAttribute("tripPlan", tripPlan);
return "trip-result";
}
@DeleteMapping("/cancel-trip/\{jobId}")
@ResponseBody
public ResponseEntity<Map<String, String>> cancelTrip(@PathVariable String jobId) {
CompletableFuture<TripPlan> future = activeJobs.get(jobId);
if (future != null && !future.isDone()) {
future.cancel(true);
activeJobs.remove(jobId);
return ResponseEntity.ok(Map.of("status", "cancelled"));
} else {
return ResponseEntity.badRequest()
.body(Map.of("error", "Job not found or already completed"));
}
}
}
Key Patterns:
- Async Execution: Uses
invokeAsync()to avoid blocking the web request - Job Tracking: Maintains a map of active futures for status polling
- htmx Integration: Returns status updates that htmx can consume for UI updates
- Error Handling: Proper exception handling and user feedback
- Resource Cleanup: Removes completed jobs from memory
- Process Options: Configures verbosity and debugging for production use
Alternative: Direct AgentProcess Creation
For simpler use cases, you can create and start an AgentProcess directly without AgentInvocation.
This approach is used in the Tripper application and works well with webhooks or form submissions where you want to:
- Start a long-running agent process
- Return immediately with a process ID
- Poll for status using the platform’s built-in controllers
@Controller
@RequestMapping("/journey")
public class JourneyController {
private final AgentPlatform agentPlatform;
public JourneyController(AgentPlatform agentPlatform) {
this.agentPlatform = agentPlatform;
}
@PostMapping("/plan")
public String planJourney(@ModelAttribute JourneyPlanForm form, Model model) {
// Convert form to domain objects
TravelBrief travelBrief = new TravelBrief(
form.getFrom(),
form.getTo(),
form.getDepartureDate(),
form.getReturnDate(),
form.getBrief()
);
// Find the appropriate agent
Agent agent = agentPlatform.agents().stream()
.filter(a -> a.getName().toLowerCase().contains("travel"))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No travel agent found"));
// Create the agent process with input bindings
AgentProcess agentProcess = agentPlatform.createAgentProcessFrom(
agent,
new ProcessOptions(
new Verbosity().withShowPrompts(true),
Budget.DEFAULT // or custom budget
),
travelBrief // Vararg inputs bound to blackboard
);
// Start the process asynchronously
agentPlatform.start(agentProcess);
// Add process ID to model for status polling
model.addAttribute("processId", agentProcess.getId());
model.addAttribute("travelBrief", travelBrief);
// Return a view that polls /api/v1/process/\{processId} for status
return "processing";
}
}
The platform provides built-in REST endpoints for status checking:
GET /api/v1/process/\{processId}- Returns process status, result, and URLsDELETE /api/v1/process/\{processId}- Terminates a running processGET /events/process/\{processId}- SSE stream of process events
Each endpoint can be individually disabled via configuration (see ).
Set the corresponding property to false to have the endpoint respond with HTTP 404:
embabel.agent.platform.rest.process-status-enabled=false
embabel.agent.platform.rest.process-kill-enabled=false
embabel.agent.platform.rest.process-events-enabled=false
A simple status polling controller can check completion and redirect to results:
@Controller
public class ProcessStatusController {
private final AgentPlatform agentPlatform;
public ProcessStatusController(AgentPlatform agentPlatform) {
this.agentPlatform = agentPlatform;
}
@GetMapping("/status/\{processId}")
public String checkStatus(
@PathVariable String processId,
@RequestParam String successView,
@RequestParam String resultModelKey,
Model model) {
AgentProcess process = agentPlatform.getAgentProcess(processId);
if (process == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Process not found");
}
switch (process.getStatus()) {
case COMPLETED:
model.addAttribute(resultModelKey, process.lastResult());
return successView;
case FAILED:
model.addAttribute("error", "Process failed: " + process.getFailureInfo());
return "error";
case TERMINATED:
model.addAttribute("error", "Process was terminated");
return "error";
default:
// Still running - return polling view
model.addAttribute("processId", processId);
return "processing";
}
}
}
When to Use Each Approach:
| Approach | Best For |
|---|---|
AgentInvocation.invokeAsync() | When you need a CompletableFuture for programmatic handling, chaining, or integration with reactive frameworks |
Direct AgentProcess creation | Webhooks, form submissions, or UI flows where you poll for status via REST/SSE |
Webhook Integration Example
For webhook-triggered workflows (e.g., JIRA, GitHub), the direct approach works well:
@RestController
@RequestMapping("/webhook")
public class WebhookController {
private final AgentPlatform agentPlatform;
public WebhookController(AgentPlatform agentPlatform) {
this.agentPlatform = agentPlatform;
}
@PostMapping("/jira/issue-created")
public ResponseEntity<Map<String, String>> onJiraIssueCreated(
@RequestBody JiraWebhookPayload payload) {
// Find agent that handles JIRA issues
Agent agent = agentPlatform.agents().stream()
.filter(a -> a.getName().contains("JiraIssue"))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No JIRA agent configured"));
// Create domain object from webhook payload
JiraIssue issue = new JiraIssue(
payload.getIssue().getKey(),
payload.getIssue().getFields().getSummary(),
payload.getIssue().getFields().getDescription()
);
// Create and start the agent process
AgentProcess process = agentPlatform.createAgentProcessFrom(
agent,
ProcessOptions.DEFAULT,
issue
);
agentPlatform.start(process);
// Return process ID for status tracking
return ResponseEntity.accepted().body(Map.of(
"processId", process.getId(),
"statusUrl", "/api/v1/process/" + process.getId(),
"sseUrl", "/events/process/" + process.getId()
));
}
}
The webhook caller can then poll /api/v1/process/\{processId} or subscribe to SSE events at /events/process/\{processId} to track progress.
Agents can also be exposed as MCP servers and consumed from tools like Claude Desktop.
Dynamic Agent and Goal Selection with Autonomy
The Autonomy class provides LLM-powered dynamic selection of agents and goals based on user intent.
Rather than programmatically choosing which agent to run, Autonomy uses an LLM to rank available agents or goals against the user’s input and select the best match.
This is how the Embabel Shell processes natural language commands.
Execution Modes
Autonomy supports two execution modes:
Closed Mode (chooseAndRunAgent): The LLM selects the most appropriate agent based on the user’s intent.
The selected agent runs in isolation using only its own actions and goals.
Open Mode (chooseAndAccomplishGoal): The LLM selects the most appropriate goal from all available goals across all agents.
Embabel then assembles a dynamic agent that can use any action from any agent to achieve that goal.
Closed Mode Example
Use closed mode when you want strict agent boundaries:
@Service
public class IntentHandler {
private final Autonomy autonomy;
public IntentHandler(Autonomy autonomy) {
this.autonomy = autonomy;
}
public AgentProcessExecution handleUserIntent(String userIntent) {
// LLM ranks all agents and selects the best match
return autonomy.chooseAndRunAgent(
userIntent,
ProcessOptions.DEFAULT
);
}
}
Open Mode Example
Use open mode when you want maximum flexibility in achieving goals:
@Service
public class GoalHandler {
private final Autonomy autonomy;
private final AgentPlatform agentPlatform;
public GoalHandler(Autonomy autonomy, AgentPlatform agentPlatform) {
this.autonomy = autonomy;
this.agentPlatform = agentPlatform;
}
public AgentProcessExecution handleUserIntent(String userIntent) {
// LLM ranks all goals and selects the best match
// Then assembles an agent from available actions to achieve it
return autonomy.chooseAndAccomplishGoal(
ProcessOptions.DEFAULT,
GoalChoiceApprover.APPROVE_ALL,
agentPlatform, // AgentScope containing goals and actions
Map.of("userInput", new UserInput(userIntent)),
new GoalSelectionOptions()
);
}
}
Using Arbitrary Bindings
chooseAndAccomplishGoal accepts any bindings, not just UserInput.
A BindingsFormatter extracts intent text from the bindings for goal ranking:
public AgentProcessExecution processTask(Task task, Person person) {
// Bindings can be any objects
Map<String, Object> bindings = Map.of(
"task", task,
"person", person
);
return autonomy.chooseAndAccomplishGoal(
ProcessOptions.DEFAULT,
GoalChoiceApprover.APPROVE_ALL,
agentPlatform,
bindings,
new GoalSelectionOptions(),
BindingsFormatter.DEFAULT // Extracts intent from bindings
);
}
The default BindingsFormatter extracts text using this priority:
PromptContributor.contribution()if the object implementsPromptContributorHasInfoString.infoString()if the object implementsHasInfoStringtoString()otherwise
You can provide a custom formatter:
BindingsFormatter customFormatter = bindings -> {
Task task = (Task) bindings.get("task");
Person person = (Person) bindings.get("person");
return String.format("Process task '%s' for %s", task.getDescription(), person.getName());
};
return autonomy.chooseAndAccomplishGoal(
ProcessOptions.DEFAULT,
GoalChoiceApprover.APPROVE_ALL,
agentPlatform,
bindings,
new GoalSelectionOptions(),
customFormatter
);
Goal Choice Approval
You can require approval before executing a selected goal:
// Approve only high-confidence matches
GoalChoiceApprover approver = GoalChoiceApprover.approveWithScoreOver(0.8);
// Or implement custom approval logic
GoalChoiceApprover customApprover = request -> {
if (request.getGoal().getName().contains("dangerous")) {
return new GoalChoiceNotApproved("Dangerous goals require manual approval");
}
return GoalChoiceApproved.INSTANCE;
};
Confidence Thresholds
Autonomy uses configurable confidence thresholds to filter matches.
If no agent or goal exceeds the threshold, a NoAgentFound or NoGoalFound exception is thrown.
Configure thresholds in application.properties:
# Minimum confidence for agent selection (0.0 to 1.0)
embabel.agent.platform.autonomy.agent-confidence-cut-off=0.6
# Minimum confidence for goal selection (0.0 to 1.0)
embabel.agent.platform.autonomy.goal-confidence-cut-off=0.6
Or override per-request using GoalSelectionOptions:
GoalSelectionOptions options = new GoalSelectionOptions(
0.5, // goalConfidenceCutOff - override platform default
null, // agentConfidenceCutOff - use platform default
false // multiGoal - whether to select multiple goals
);
Shell Usage
The Embabel Shell uses Autonomy for the execute (x) and choose-goal commands:
# Closed mode (default) - select best agent
x "Find a horoscope for Alice who is a Scorpio"
# Open mode - select best goal, use any actions
x "Find a horoscope for Alice who is a Scorpio" -o
# Show goal rankings without executing
choose-goal "Find a horoscope for Alice"
See and for full command and flag documentation.
Handling Selection Failures
try {
return autonomy.chooseAndRunAgent(userIntent, ProcessOptions.DEFAULT);
} catch (NoAgentFound e) {
// No agent matched with sufficient confidence
logger.info("No matching agent. Rankings: {}", e.getAgentRankings());
return fallbackResponse();
} catch (NoGoalFound e) {
// No goal matched with sufficient confidence (open mode)
logger.info("No matching goal. Rankings: {}", e.getGoalRankings());
return fallbackResponse();
} catch (GoalNotApproved e) {
// Goal was rejected by the approver
logger.info("Goal not approved: {}", e.getReason());
return requiresApprovalResponse();
}




