Approve risky actions
Some actions should not run until a person says yes: deleting data, sending email, moving money. Mark them with @ApprovalRequired, and the executor refuses to run them unless the call carries an approved ApprovalToken.
The framework checks tokens; it does not request them. Your code mints the token, shows it to a reviewer, records the decision and passes the approved token with the call.
Mark the action
public static class AccountActions {
@ActionSpec(type = ActionType.LOCAL, description = "Delete a user account permanently")
@ApprovalRequired(reason = "Permanent data deletion")
public String deleteUser(String userId) {
return "deleted " + userId;
}
}reason defaults to "Sensitive operation" and appears in the refusal message. Put @ApprovalRequired on the method. @Security(approvalRequired = true) is metadata only: nothing enforces it.
Mint, approve, run
static String requestAndRun(Agent agent, AgentPrincipal principal, String userId) {
ApprovalTokenStore store = new InMemoryApprovalTokenStore();
// 1. Mint a pending token bound to the agent's principal and the action's method name.
ApprovalToken token = store.createToken(principal.id(), "deleteUser",
Map.of("userId", userId), Duration.ofMinutes(5));
// 2. Show token.getTokenId() to a reviewer; their decision comes back through your own endpoint.
store.approve(token.getTokenId(), "admin@example.com");
// 3. Pass the approved token with the call. The executor checks and consumes it.
Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
params.put("_approval_token", store.getToken(token.getTokenId()).orElseThrow());
try {
return (String) agent.executeAction("deleteUser", params);
} catch (ApprovalRequiredException e) {
return "refused: " + e.getMessage(); // missing, unbound, pending, rejected, expired or used
}
}Before the action body runs, the executor:
- Throws
ApprovalRequiredExceptionif_approval_tokenis missing or is not anApprovalToken. - Throws if the executor has no principal.
AgentBuilderrequires one, so agents it builds always have it. - Checks that the token's agent id equals the principal's
id()and its action name equals the action's name. This check comes first, so a caller with the wrong binding cannot burn the token. - Consumes the token. A single-use token moves to
CONSUMED; a token that is pending, rejected, expired or already consumed is refused. - Removes
_approval_tokenfrom the arguments, unless the method declares a parameter with that name.
The token's parameters map is for the reviewer. The executor does not compare it with the call's arguments, so check them yourself if the approval covers specific values.
Do not call store.validateAndConsume(...) before passing the token. It consumes a single-use token, and the executor then refuses the call.
Token lifecycle
| Status | Meaning |
|---|---|
PENDING | Created, waiting for a decision |
APPROVED | Approved; a multi-use token stays here after each use |
REJECTED | Rejected, with getRejectedBy() and getRejectionReason() |
EXPIRED | The time to live passed before use |
CONSUMED | A single-use token that authorized one call |
approve(approver) succeeds on a pending or approved token that has not expired. reject(rejector, reason) succeeds only on a pending one. Tokens from createToken are single-use. For a multi-use token, build it with ApprovalToken.builder() and singleUse(false), then store.save(token). The builder's default time to live is five minutes.
Store tokens
ApprovalTokenStore holds tokens between the request and the decision: createToken, save, getToken, approve, reject, listPending, listPendingForAgent, cleanupExpired and delete. The only shipped implementation is InMemoryApprovalTokenStore, which loses its tokens on restart. For tokens that survive restarts or span several processes, implement ApprovalTokenStore over your own database.
Ask mid-action with InterruptService
InterruptService pauses the calling thread until a person answers, without a token. Use it inside an action or a plan step when the question only comes up at run time.
static InterruptService interruptService() {
return new InterruptService(
request -> System.out.println("Needs a decision: " + request.interruptId() + " " + request.payload()),
Duration.ofMinutes(5));
}
static boolean askFirst(InterruptService interrupts, String userId) {
InterruptResult result = interrupts.requestInterrupt(
InterruptReason.HUMAN_APPROVAL,
Map.of("action", "deleteUser", "userId", userId),
Duration.ofMinutes(10)); // blocks until resume, cancel or timeout
return result.shouldProceed();
}
static void decide(InterruptService interrupts, String interruptId, boolean approved) {
interrupts.resume(interruptId, approved,
approved ? Map.of("approver", "admin@example.com") : Map.of("reason", "Too risky"));
}The listener receives each InterruptRequest (id, reason, payload, timeout, creation time). Send it to your UI, and call resume or cancel from the handler that receives the answer. The framework provides no HTTP route for this. requestInterruptAsync returns a CompletableFuture<InterruptResult> instead of blocking.
shouldProceed() is true only for APPROVED. The other outcomes are REJECTED, TIMED_OUT, CANCELLED and ERROR. InterruptReason has HUMAN_APPROVAL, UPLOAD_REQUIRED, POLICY_VIOLATION, ERROR_RECOVERY, CONFIRMATION and CUSTOM.