How to Implement Goal Approval Using the GoalChoiceApprover Interface in Embabel

Implement the GoalChoiceApprover interface and register it as a Spring bean to intercept, approve, reject, or modify goal proposals before they become active goals in the Embabel agent framework.

The Embabel agent framework (embabel/embabel-agent) evaluates potential actions by generating goal proposals during the planning phase. Before a proposal becomes an active goal, it passes through the GoalChoiceApprover hook, allowing you to enforce safety policies, adjust metadata, or filter inappropriate suggestions.

Understanding the GoalChoiceApprover Interface

The GoalChoiceApprover interface lives in the core API module at embabel-agent-api/src/main/java/com/embabel/agent/api/goal/GoalChoiceApprover.java. It defines a single contract that the planning engine invokes for every candidate goal.

Interface Method Signature

package com.embabel.agent.api.goal;

/**
 * Called by the planning engine when a new {@link GoalProposal} is created.
 * Implementations may approve, transform, or reject the proposal.
 */
public interface GoalChoiceApprover {

    /**
     * Decide whether the supplied proposal should be turned into a concrete {@link Goal}.
     *
     * @param proposal the candidate goal proposal generated by the agent’s plan
     * @return an {@link ApproverResult} describing the outcome
     */
    ApproverResult approve(GoalProposal proposal);
}

The approve() method receives a GoalProposal object containing the task description, confidence score, and originating tool metadata. Your implementation must return an ApproverResult that instructs the engine how to proceed.

The ApproverResult Outcome

The ApproverResult class (located in the same package) provides three static factory methods:

  • ApproverResult.approved(GoalProposal proposal) – Accepts the proposal unchanged and converts it into a concrete goal.
  • ApproverResult.rejected(String reason) – Discards the proposal; the planner attempts to generate a new one. The reason string logged for debugging.
  • ApproverResult.modified(GoalProposal proposal) – Approves a transformed version of the proposal, useful for scrubbing sensitive data or shortening descriptions.

If multiple approver beans are registered, the engine invokes them sequentially. A REJECTED result halts the chain immediately, while APPROVED or MODIFIED allows the next approver to further refine the proposal.

Implementing Custom Approval Logic

You can implement GoalChoiceApprover as a standard Spring component. The framework auto-discovers these beans at runtime and injects them into the planning pipeline.

Simple Always-Approve Pattern

Use this skeleton for testing or when you want to observe proposals without blocking them:

package com.myapp.approvers;

import com.embabel.agent.api.goal.*;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

/**
 * A no-op approver that always approves the incoming proposal.
 */
@Component
@Order(1)  // low order value = runs early
public class PermitAllGoalApprover implements GoalChoiceApprover {

    @Override
    public ApproverResult approve(GoalProposal proposal) {
        System.out.println("Approving: " + proposal.getDescription());
        return ApproverResult.approved(proposal);
    }
}

Conditional Rejection for Safety

Block proposals containing forbidden keywords before they reach execution:

package com.myapp.approvers;

import com.embabel.agent.api.goal.*;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.util.Set;

@Component
@Order(10)  // runs after generic approvers
public class SafeWordGoalApprover implements GoalChoiceApprover {

    private static final Set<String> BLOCKED = Set.of("delete", "shutdown", "format");

    @Override
    public ApproverResult approve(GoalProposal proposal) {
        String lower = proposal.getDescription().toLowerCase();
        for (String word : BLOCKED) {
            if (lower.contains(word)) {
                return ApproverResult.rejected("Contains unsafe keyword: " + word);
            }
        }
        return ApproverResult.approved(proposal);
    }
}

When this bean returns rejected(), the planning engine abandons the current proposal and attempts to generate an alternative.

Modifying Proposals Before Approval

Transform proposals by rewriting descriptions or adjusting metadata:

package com.myapp.approvers;

import com.embabel.agent.api.goal.*;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
@Order(20)  // runs after safety checks
public class TruncateDescriptionApprover implements GoalChoiceApprover {

    private static final int MAX_LEN = 120;

    @Override
    public ApproverResult approve(GoalProposal proposal) {
        String desc = proposal.getDescription();
        if (desc.length() > MAX_LEN) {
            String truncated = desc.substring(0, MAX_LEN) + "...";
            GoalProposal modified = proposal.withDescription(truncated);
            return ApproverResult.modified(modified);
        }
        return ApproverResult.approved(proposal);
    }
}

The GoalProposal.withDescription(String) method creates a shallow copy with the new text, preserving the original confidence score and tool metadata.

Registering Approvers in the Agent Pipeline

Spring Bean Auto-Discovery

When using the Embabel Spring Boot starter, the EmbabelAgentBuilder automatically collects all beans implementing GoalChoiceApprover from the application context. The execution order respects Spring’s @Order annotation (lower values execute first).

import com.embabel.agent.api.EmbabelAgentBuilder;

AgentBuilder builder = EmbabelAgentBuilder.builder()
        .withSpringContext(applicationContext)
        .build();

Manual Registration without Spring

If you are not using Spring, register approvers programmatically via the builder:

EmbabelAgentBuilder builder = EmbabelAgentBuilder.builder();
builder.addGoalChoiceApprover(new PermitAllGoalApprover());
builder.addGoalChoiceApprover(new SafeWordGoalApprover());

Agent agent = builder.build();

The registration sequence determines execution order; the first registered approver runs first.

Summary

  • The GoalChoiceApprover interface in embabel-agent-api/src/main/java/com/embabel/agent/api/goal/GoalChoiceApprover.java defines the approve(GoalProposal) method for intercepting goal proposals.
  • Return ApproverResult.approved(), rejected(String), or modified() to control whether a proposal becomes an active goal.
  • Annotate implementations with @Component and @Order to participate in Spring’s auto-discovery and control execution priority.
  • Use GoalProposal.withDescription() to create modified copies when you need to sanitize or truncate text before approval.
  • Register approvers manually via EmbabelAgentBuilder.addGoalChoiceApprover() when operating outside of Spring.

Frequently Asked Questions

What happens when multiple GoalChoiceApprover beans conflict?

The Embabel planning engine chains approvers sequentially according to their @Order value or registration order. If any approver returns REJECTED, the proposal is discarded immediately and the chain stops. If an earlier approver returns MODIFIED, subsequent approvers receive the altered proposal, allowing cumulative transformations.

Can I modify the goal description before approval?

Yes. Create a new GoalProposal instance using proposal.withDescription(String) to produce a modified copy, then return ApproverResult.modified(updatedProposal). This technique is useful for trimming verbose LLM outputs, adding prefixes, or removing sensitive information.

How do I reject a goal proposal with a specific reason?

Invoke ApproverResult.rejected(String reason) and pass a descriptive message. While the rejected proposal is discarded, the reason string appears in logs and debugging output, helping you trace why specific goals were blocked by your GoalChoiceApprover implementation.

Does GoalChoiceApprover require Spring?

No. While the embabel/embabel-agent repository provides first-class Spring support via @Component scanning, you can use the interface in plain Java by calling addGoalChoiceApprover() directly on the EmbabelAgentBuilder. This is useful for lightweight deployments or custom dependency injection frameworks.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →