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 URLs
  • DELETE /api/v1/process/\{processId} - Terminates a running process
  • GET /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:

ApproachBest For
AgentInvocation.invokeAsync()When you need a CompletableFuture for programmatic handling, chaining, or integration with reactive frameworks
Direct AgentProcess creationWebhooks, 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.

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:

  1. PromptContributor.contribution() if the object implements PromptContributor
  2. HasInfoString.infoString() if the object implements HasInfoString
  3. toString() 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();
}

Was this page helpful?

Share