Implementing human-in-the-loop approval in Java AI agents¶
Autonomous agents make mistakes. They misclassify inputs, call the wrong tools, or take irreversible actions (refunds, emails, database writes) based on a hallucinated context. For high-stakes workflows, the answer is not to make the agent smarter. It is to require a human to approve before the action executes.
This tutorial shows how to implement human-in-the-loop approval in Java with AF4J: pause an agent graph before a sensitive node, notify a human, and resume execution from a checkpoint after approval, without losing the work already done.
The problem with naive approaches¶
The obvious approach is to put an if statement before the sensitive call:
This breaks immediately in production:
- You cannot block a thread waiting for an email reply
- If the server restarts while waiting, the state is lost
- The agent code is now coupled to your notification system
- There is no audit trail of what was approved, and when
AF4J solves this with ApprovalGate, a gate evaluated before a node executes. When approval is required, the graph pauses, persists a checkpoint, and returns an interrupted result. The server is free. The state is safe. A human approves asynchronously. The graph resumes exactly where it left off.
Setup¶
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>com.github.datallmhub.agentflow4j</groupId>
<artifactId>agentflow4j-starter</artifactId>
<version>v1.0.0</version>
</dependency>
<dependency>
<groupId>com.github.datallmhub.agentflow4j</groupId>
<artifactId>agentflow4j-checkpoint</artifactId>
<version>v1.0.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
</dependencies>
The checkpoint store needs a datasource. Any JDBC-compatible database works:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/agentflow
username: ${DB_USER}
password: ${DB_PASS}
The examples below share these typed state keys:
public final class PaymentKeys {
public static final StateKey<Double> AMOUNT = StateKey.of("payment.amount", Double.class);
public static final StateKey<String> RECIPIENT = StateKey.of("payment.recipient", String.class);
public static final StateKey<String> RISK_LEVEL = StateKey.of("payment.risk", String.class);
}
Step 1: Define the gate¶
ApprovalGate is a functional interface. The simplest factory requires approval for specific node names:
// Require approval before the "transfer" and "delete" nodes
ApprovalGate gate = ApprovalGate.requireFor("transfer", "delete");
For dynamic rules, where approval is needed only when a condition holds, use ApprovalGate.when:
// Require approval only for amounts above 500
ApprovalGate gate = ApprovalGate.when(
(node, ctx) -> "transfer".equals(node) && ctx.get(AMOUNT) > 500.0,
"Transfer above 500 requires manager sign-off");
Compose several gates with .and():
ApprovalGate gate = ApprovalGate.requireFor("transfer")
.and(ApprovalGate.when(
(node, ctx) -> "HIGH".equals(ctx.get(RISK_LEVEL)),
"High-risk transaction"));
Step 2: Wire the gate into the graph¶
Gates are configured on the graph, not on individual agents. This keeps agent code free of governance concerns:
@Configuration
class PaymentGraphConfig {
@Bean
CheckpointStore checkpointStore(DataSource dataSource, PlatformTransactionManager txManager) {
// Register every custom state type the graph writes, so checkpoints can be serialized
StateTypeRegistry types = new StateTypeRegistry()
.register(AMOUNT)
.register(RECIPIENT)
.register(RISK_LEVEL);
JdbcCheckpointStore store = new JdbcCheckpointStore(
new JdbcTemplate(dataSource), txManager, new JacksonCheckpointCodec(types));
store.createTableIfMissing();
return store;
}
@Bean
RunLogStore runLogStore() {
return new InMemoryRunLogStore();
}
@Bean
AgentGraph paymentGraph(Agent classifier, Agent transferAgent, Agent confirmationAgent,
CheckpointStore checkpointStore, RunLogStore runLogStore,
ApprovalNotifier approvalNotifier) {
return AgentGraph.builder()
.name("payment-workflow")
.addNode("classify", classifier)
.addNode("transfer", transferAgent)
.addNode("confirm", confirmationAgent)
.addEdge("classify", "transfer")
.addEdge("transfer", "confirm")
// the gate fires before "transfer" executes
.approvalGate(ApprovalGate.requireFor("transfer"))
// the checkpoint lets the run resume, even after a restart
.checkpointStore(checkpointStore)
// the run log is the audit trail
.runLog(runLogStore)
// notifies the approver when the gate fires (Step 4)
.listener(approvalNotifier)
.build();
}
}
InMemoryRunLogStore is fine for a demo; implement RunLogStore against your database to keep the audit trail across restarts.
Step 3: Start the run and handle the interrupted result¶
Give each run an id: it is the handle you resume with later. When the gate fires, invoke returns immediately with result.isInterrupted() == true:
@Service
public class PaymentService {
private final AgentGraph graph;
private final CheckpointStore checkpoints;
PaymentService(AgentGraph graph, CheckpointStore checkpoints) {
this.graph = graph;
this.checkpoints = checkpoints;
}
public PaymentResult submit(Payment payment) {
String runId = "payment-" + payment.id();
AgentContext ctx = AgentContext.of("Pay " + payment.recipient())
.with(AMOUNT, payment.amount())
.with(RECIPIENT, payment.recipient())
.with(RISK_LEVEL, payment.riskLevel());
AgentResult result = graph.invoke(ctx, RunOptions.ofRunId(runId));
if (result.isInterrupted()) {
return PaymentResult.pendingApproval(runId);
}
return PaymentResult.completed(result.text());
}
public PaymentResult approve(String runId, String approver) {
AgentResult result = graph.resume(runId, ResumeOptions.ofApproval("transfer")
.withMessages(new UserMessage("Transfer approved by " + approver)));
if (result.isInterrupted()) {
// another gate further down the graph is waiting for approval
return PaymentResult.pendingApproval(runId);
}
return PaymentResult.completed(result.text());
}
public PaymentResult reject(String runId, String reason) {
// a rejected run is simply never resumed: drop its checkpoint
checkpoints.delete(runId);
return PaymentResult.rejected(reason);
}
}
ResumeOptions.ofApproval("transfer") names the node being approved. The approver's identity travels as a message: it becomes part of the checkpointed conversation, visible to the downstream agents.
Step 4: Notify the approver¶
Rather than notifying from the service, react to the gate itself with a listener. onApprovalRequired fires whenever a gate pauses a run, whatever code started it:
@Component
class ApprovalNotifier implements AgentListener {
private final NotificationService notifications;
ApprovalNotifier(NotificationService notifications) {
this.notifications = notifications;
}
@Override
public void onApprovalRequired(String graphName, ApprovalRequest request) {
notifications.requestApproval(request.nodeName(), request.reason());
}
}
To include the run id in the message, read it from the checkpoint in onCheckpoint, which fires right after the paused run is persisted and carries checkpoint.runId().
Step 5: Expose approval endpoints¶
Wire the service into a REST controller. The approver clicks a link in their email or Slack message that hits one of these endpoints:
@RestController
@RequestMapping("/approvals")
public class ApprovalController {
private final PaymentService paymentService;
ApprovalController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@PostMapping("/{runId}/approve")
public ResponseEntity<String> approve(@PathVariable String runId,
@AuthenticationPrincipal UserDetails user) {
PaymentResult result = paymentService.approve(runId, user.getUsername());
return ResponseEntity.ok(result.toString());
}
@PostMapping("/{runId}/reject")
public ResponseEntity<String> reject(@PathVariable String runId, @RequestParam String reason) {
paymentService.reject(runId, reason);
return ResponseEntity.ok("Payment rejected.");
}
}
Protect both endpoints with Spring Security so only users with the right role can approve.
What happens on resume¶
When graph.resume(runId, ResumeOptions.ofApproval("transfer")) is called:
- The checkpoint store loads the persisted context for this run
transferis added to the approval marker on the context- The graph continues from the checkpointed node:
classifydoes not re-execute - The gate re-evaluates, sees the approval marker and lets
transferrun transferexecutes, thenconfirm, then the graph completes and the checkpoint is deleted
If the server restarted between submission and approval, nothing is lost: the checkpoint store holds the full context.
Audit trail¶
With a RunLogStore configured, every step of the run is recorded under its run id:
#0 NODE_ENTER node=classify
#1 NODE_EXIT node=classify (412ms)
#2 TRANSITION node=classify — classify→transfer
#3 NODE_ENTER node=transfer
#4 APPROVAL_REQUIRED node=transfer — approval.required:transfer
#5 GRAPH_COMPLETE — approval required at transfer
The resumed run appends its events to the same log, numbered from #0 again. The run log records what the graph did; record who approved in your own audit table from PaymentService.approve.
Testing without a real approver¶
Use MockAgent from agentflow4j-test and the in-memory store to unit-test approval flows:
@Test
void transferWaitsForApproval() {
AgentGraph graph = AgentGraph.builder()
.addNode("transfer", MockAgent.returning("ok"))
.approvalGate(ApprovalGate.requireFor("transfer"))
.checkpointStore(new InMemoryCheckpointStore())
.build();
AgentResult first = graph.invoke(AgentContext.of("transfer 600"), RunOptions.ofRunId("run-1"));
assertThat(first.isInterrupted()).isTrue();
assertThat(first.interrupt().reason()).isEqualTo("approval.required:transfer");
AgentResult resumed = graph.resume("run-1", ResumeOptions.ofApproval("transfer"));
assertThat(resumed.isInterrupted()).isFalse();
assertThat(resumed.text()).isEqualTo("ok");
}
No HTTP, no database, no Slack: the test runs in milliseconds.
Next steps¶
- Approval via Slack: send an interactive Slack message with Approve / Reject buttons, ~30 lines
- Stop your agent burning $1000 overnight: combine
ApprovalGatewithBudgetPolicyandToolPolicy - Observability: every lifecycle hook, including
onApprovalRequired