The Agent Asks First: Human-in-the-Loop with Embabel's WaitFor
Every agent I'd built in Embabel so far ran to completion on its own. That's fine for writing a story or looking up a balance. It is emphatically not fine for moving money. In regulated work the dangerous agent isn't the one that hallucinates — it's the one that acts without asking. So the pattern that matters most to me is human-in-the-loop: the agent proposes, a human approves, and only then does anything happen. Embabel has a clean primitive for it, WaitFor, and the way it composes with the type system is genuinely elegant.
Approval as a type gate, not an if-check
The naive version is an if (approved) { execute() } buried in one method. Easy to forget, easy to bypass, hard to prove. The Embabel version makes the unapproved path unrepresentable. I modelled three types and let the plan graph do the enforcing:
public record ProposedTransfer(int amountEur, String fromAccount, String toAccount) {}
public record ApprovedTransfer(ProposedTransfer transfer) {} // exists ONLY after a human approves
public record TransferReceipt(ApprovedTransfer approved, String reference) { /* ... */ }
The key move: the goal action executeTransfer consumes an ApprovedTransfer, and the only thing that can produce one is a human confirmation. No approval, no ApprovedTransfer on the blackboard, no way to reach the goal. The safety property isn't enforced by a check I wrote — it's enforced by the compiler and the planner.
@Action
ProposedTransfer proposeTransfer(UserInput userInput, Ai ai) {
return ai.withDefaultLlm()
.createObject("Extract the transfer... " + userInput.getContent(), ProposedTransfer.class);
}
@Action
ApprovedTransfer confirmTransfer(ProposedTransfer proposed) {
return WaitFor.confirmation(
new ApprovedTransfer(proposed),
"Approve transfer of %d EUR from %s to %s?".formatted(
proposed.amountEur(), proposed.fromAccount(), proposed.toAccount()));
}
@AchievesGoal(description = "The approved transfer has been executed")
@Action
TransferReceipt executeTransfer(ApprovedTransfer approved) {
// a real impl calls the ledger/payments service here
return new TransferReceipt(approved, "TRF-" + ref(approved));
}
What WaitFor.confirmation actually does
I didn't want to trust the docs on the semantics, so I read the source. WaitFor.confirmation(payload, message) suspends the agent into a WAITING state and, on the human's answer, does one of two things — and this is the whole ballgame:
- Accepted →
agentProcess += payload. TheApprovedTransferis promoted to the blackboard, soexecuteTransferbecomes reachable and the plan continues. - Rejected →
ResponseImpact.UNCHANGED. Nothing is promoted. The goal is unreachable, so the agent simply stops having done nothing.
That's why I don't need a rejection branch. "Reject means nothing happens" falls out of the type graph for free. In a domain where you have to prove that money can't move without sign-off, "the unapproved state cannot be constructed" is a far stronger statement than a code review of an if.
Run it with x, not AgentInvocation
This one bit me conceptually, so it's worth flagging. My earlier agents were driven programmatically with AgentInvocation.create(platform, X.class).invoke(...), which expects a terminal result. A human-in-the-loop agent doesn't have one at the point it pauses — it throws a ProcessWaitingException. The Embabel shell's execute / x command is built for exactly this: it catches that exception and its TerminalServices renders the ConfirmationRequest as a terminal y/n prompt. So you drive it in natural language and answer inline:
x "Please transfer 500 EUR from ACC-100 to ACC-200"
# Approve transfer of 500 EUR from ACC-100 to ACC-200? [y/n]
Note the entry points differ: x uses the platform's goal ranking to pick the agent from intent, whereas AgentInvocation targets a result type directly. Both are valid; HITL wants the former.
What the blackboard proves
The output is a nice audit trail in itself. The blackboard shows the whole causal chain: the original UserInput, the ProposedTransfer the LLM extracted, the ConfirmationRequest that paused it, the ApprovedTransfer that only appeared because I said yes, and finally the TransferReceipt. All three hasRun flags are true, in order. And the cost line is telling: one LLM call, 223 prompt tokens, 91 completion. The model did exactly one job — parse the sentence into a typed ProposedTransfer. Everything after that — the pause, the gate, the execution — is deterministic JVM code. The expensive, non-deterministic component is used sparingly and boxed in at the edge.
Takeaway
Human-in-the-loop is where agent frameworks meet the real world, and most of them bolt it on as a callback or a flag. Embabel folds it into the same type-driven planning model as everything else: a pause is just an action that returns a value the human has to unlock. Model the approved state as its own type, gate the goal behind it, and the framework guarantees the thing you actually care about — that nothing irreversible happens without a person in the loop. For banking, that's not a feature. That's the requirement.