Human-in-the-Loop Approval¶
A risk level tells you how dangerous a tool is. A sandbox limits what it can touch. Human-in-the-loop (HITL) approval decides whether a specific call runs at all - it pauses an individual tool call and waits for a person to approve or decline before the tool executes.
This is the runtime checkpoint that the AI Agent Tool Safety and MCP Server Safety pages refer to. It is the last gate before a call fires, and it sits between the sandbox (which judges what a tool can do) and the agent (which judges when to call it).
Pages this gate connects to:
- Agent Loop - the round governance this gate runs as one interceptor of
- MCP Server Safety - how a tool from an external server is wrapped so it can carry the flag
- AI Agent Tool Safety - the sandbox that bounds what an approved call may touch
- Safe Tool Specification - the
humanInTheLoopblock that declares the flag - AI Agent Observability - where approvals, declines, and timeouts are recorded
- Application - where the gate sits in the runtime
Overview¶
The whole model is one flag, enforced once, wherever the call enters:
- One per-tool flag. Every tool carries a
HumanInTheLooppolicy on itsToolSpec(REQUIRED/DISABLED). There is no second place to configure approval - both gates below read this one flag. - A tool you authored carries the flag itself. Set it in Tool Studio; publishing applies it automatically.
- A tool from an external MCP server is wrapped so it carries the flag too. You cannot rely on a third-party server to implement approval, so the playground proxies the upstream tool through its own built-in server, wrapping it as one of its own
ToolSpecs with the flag attached. From that point it is gated exactly like a tool you wrote. - The gate fires at the caller's entry point - an approval dialog for on-device Agentic Chat, an MCP
elicitation/createround-trip for an external MCP client. Both consult the same flag, and a call is never asked twice.
flowchart TB
AUTH["Tools you authored<br/>(Tool Studio)<br/>carry the flag natively"]
EXT["Tools from external<br/>MCP servers<br/>wrapped / proxied to carry it"]
AUTH --> SPEC["Every tool is a ToolSpec<br/>with one approval flag"]
EXT --> SPEC
SPEC --> GATE["Runtime approval gate<br/>fires before the tool runs"]
GATE --> APPROVE["Approve -> run the tool"]
GATE --> DECLINE["Decline -> not run"]
The rest of this page details each piece: what it guarantees, the policy itself, where the flag comes from, the two enforcement points, and - the part that ties it together - the full path a proxied external tool takes.
What it guarantees¶
When a tool is configured to require approval, no caller can execute it without an explicit human ACCEPT, and every decision fails safe:
- No silent execution. A
REQUIREDtool always asks first. - Fail-safe default. A timeout, a closed dialog, a client with no elicitation support, a transport error - every non-
ACCEPToutcome is treated as a decline, and the tool does not run. - The model is told. A decline returns a message back into the conversation so the agent does not silently retry.
- Asked once, not twice. A call that is already gated on this device is not gated again downstream (see loopback de-duplication).
The per-tool policy¶
Every tool carries an optional approval policy, defined by ToolManifest.HumanInTheLoop and stored on the tool's ToolSpec:
record HumanInTheLoop(Mode mode, String promptTemplate) {
enum Mode { DISABLED, REQUIRED }
}
| Mode | On-device Agentic Chat | External MCP client |
|---|---|---|
REQUIRED |
Ask on every call | Ask on every call (MCP elicitation) |
DISABLED |
Never ask | Never ask |
The chat-side decision logic is centralized in ToolSpecService.requiresApproval, which gates a call only when its mode is REQUIRED. The server-side MCP gate likewise decorates only REQUIRED tools.
The optional promptTemplate customizes the question text. {toolName} and {args} are substituted at call time; the default is "Run '{toolName}' with arguments {args}?".
Where the approval flag comes from¶
The single policy above is attached to a tool by one of two routes, depending on who authored the tool:
- Authored (Tool Studio) tools carry
humanInTheLoopon their ownToolSpec. The author chooses the mode; publishing registers the tool on the built-in server already wearing the flag (ToolSpecService.addMcpTooldecorates the registration throughMcpServerHitlToolGate). - External MCP server tools are the interesting case. The playground has no way to know whether a third-party server implements any approval step - it only speaks the upstream tool's
tools/callcontract. So when you re-expose such a tool through the Composed Tools drawer, the playground does not hand it to the agent raw. It proxies the tool: it wraps the upstream callback in aWrappedExternalToolCallbackand registers it on the built-in server viaToolSpecService.addExternalMcpTool(callback, hitl). When you tick HITL on that tool, the playground attaches aREQUIREDpolicy to a runtimeToolSpecentry in itsexternalToolSpecsregistry. That entry is separate from authored tools and is not persisted or shown in Tool Studio's authored-tool list.
This is why proxying exists for the safety story: re-exposing an external tool through your own server is what lets you own the approval gate, instead of trusting a server you did not write. Once wrapped, the upstream tool is gated by exactly the same flag and the same two enforcement points as a tool you authored. (The MCP Server Safety page covers the other things the wrapper adds at the same moment - risk scoring, secret masking, logging.)
The two enforcement points¶
There is one per-tool policy, enforced wherever a call enters - and the split is by caller (entry point), not by tool type. Built-in tools and re-exposed external tools carry the same flag and are gated the same way; what differs is who is calling:
- Agentic Chat (in-process agent loop) - every tool call the chat makes, built-in or re-exposed external, runs through a single
AgentLoopManagerround. TheChatClientinstalls Spring AI'sToolCallingAdvisor(orToolSearchToolCallingAdvisorfor dynamic discovery) with this manager as itsToolCallingManager; the manager runs a chain of round interceptors, the first business one beingHitlApprovalInterceptor, which asks for approval before any gated tool runs. The check isrequiresApproval(toolName), which finds the flag on the authoredToolSpecor on the wrapped external one. The wider loop this sits in is covered in Agent Loop. - External MCP client over
/mcp(Claude Desktop, another app) - this caller never touches theChatClient, its advisor chain, or theToolCallingManager. The MCP server SDK invokes the tool'scallHandlerdirectly, so the only place to gate it is by wrapping that handler at registration (McpServerHitlToolGate), which asks via MCP elicitation.
The two never fire for the same call: when Chat reaches a built-in or proxied tool through the self-loopback MCP client, the server-side gate sees the loopback caller and skips - the chat loop already asked (see loopback de-duplication).
flowchart TB
POLICY["One approval flag per tool<br/>(REQUIRED · same for built-in and proxied)"]
POLICY --> CALLER{"Who calls<br/>the tool?"}
CALLER -->|"Agentic Chat<br/>(in-process)"| A1
CALLER -->|"External MCP client<br/>(Claude Desktop, ...)"| B1
subgraph CHAT["On-device chat gate"]
direction TB
A1["Chat agent loop<br/>(AgentLoopManager)"]
A2["Approval check<br/>before each tool call"]
A3["Vaadin dialog<br/>Approve / Decline"]
A1 --> A2 --> A3
end
subgraph SERVER["External-client gate"]
direction TB
B1["Built-in /mcp<br/>call handler"]
B2["Server gate<br/>MCP elicitation"]
B1 --> B2
end
A3 -->|Approve| RUN["Run the tool"]
A3 -->|"Decline / timeout"| STOP["Not run"]
B2 -->|Approve| RUN
B2 -->|"Decline / error"| STOP
B2 -.->|"self-loopback:<br/>chat already asked"| RUN
On-device chat - approval dialog¶
When the on-device agent loop is about to execute tool calls, the HitlApprovalInterceptor inspects the round. For every call whose tool requiresApproval, it raises a HumanQuestion keyed by the tool call id and asks the HumanQuestionHandler carried in the tool context. In Agentic Chat that handler is ChatHumanQuestionHandler, which renders all of the round's approval questions in a single Vaadin dialog - a ConfirmDialog (Approve / Decline) when there is one, or one row per call when there are several - on the chat UI. This path uses a dialog, not MCP elicitation; chat is in-process, so there is no protocol round-trip to make.
Each HumanQuestion also carries the tool's inherent risk level (ToolSpecService.gatingRiskLevel: sandbox posture for authored tools, composed level for proxied external tools - never the HITL -1 display credit, since this dialog is that mitigation). The dialog renders it as the standard risk chip and escalates with it: L4 adds a warning line and a red Approve; L5 disables Approve until an explicit acknowledgment checkbox is ticked, so a destructive call can never be approved by a reflex Enter press.
- Approve → the call executes normally.
- Decline → the call is removed from the batch and a
ToolResponseis synthesized telling the model the user declined, it was not executed, and not to call it again for this request. The tool is also remembered as declined for the rest of the turn, so a re-request is auto-declined without a second dialog. The model continues with the remaining (approved) calls, if any. - Timeout (the
approval-timeout-secondssetting, default 2 minutes), dialog closed, or UI error → decline (fail-safe).
Keying by tool call id matters: if the model emits the same gated tool with the same arguments twice in one round, each call gets its own verdict, so declining both cannot let the first slip through. All the round's questions share one dialog, so the wait never stacks past the stream's inter-signal timeout. Approved and declined calls in the same round are handled together, and the original ordering is preserved so the conversation history stays consistent.
External MCP client - elicitation¶
When a tool is published on the built-in MCP server, ToolSpecService wraps its specification with McpServerHitlToolGate when the tool's mode is REQUIRED. At call time the gate, before delegating to the real handler:
- Loopback? If the caller is this device's own chat (the self-loopback client), pass straight through - the chat dialog already asked.
- Elicitation capable? If the calling client did not advertise the
elicitationcapability, deny - there is no way to ask, so the call cannot be approved. - Ask. Issue
exchange.createElicitation(prompt, schema). OnACCEPTrun the tool; onDECLINE/CANCELreturn a deniedCallToolResult(isError = true). - Any error (transport drop, SDK timeout, exception) → deny (fail-safe).
The prompt schema is an empty object - this is a confirmation, not a data form - so a compliant client renders a plain approve / decline card. Because MCP elicitation has no severity field, the gate prefixes the message text with the tool's risk level (e.g. [risk L5 Critical] Approve running tool 'deleteFile' ...), so external clients see the same signal the chat dialog shows as a chip. (In the playground's own MCP Inspector, that arrives as an ElicitationRequestPrimitive card.)
Proxied external tools - the full runtime path¶
This is the path that ties the whole model together, and it is the reason proxying exists. When Agentic Chat (with the built-in MCP server enabled) calls a re-exposed external tool, the call is approved once on this device and only then reaches the upstream server. The flag was attached when the tool was wrapped (above); here is what happens at call time:
sequenceDiagram
autonumber
participant M as Chat model
participant TCM as Chat tool loop
participant DLG as Approval dialog
participant LB as Self-loopback client
participant GATE as Built-in /mcp gate
participant WRAP as Wrapped external tool
participant EXT as External MCP server
M->>TCM: call alias(args)
TCM->>TCM: requiresApproval(alias)?<br/>yes - externalToolSpecs → REQUIRED
TCM->>DLG: approve / decline?
alt Declined or timeout
DLG-->>TCM: decline
TCM-->>M: synthesized "declined, not run"<br/>(upstream never contacted)
else Approved
DLG-->>TCM: approve
TCM->>LB: execute alias(args)
LB->>GATE: tools/call alias (caller = loopback)
GATE->>GATE: isLoopback? → skip elicitation
GATE->>WRAP: delegate.call(args)
WRAP->>EXT: upstream tools/call
EXT-->>WRAP: CallToolResult
WRAP-->>GATE: result (risk MDC, secrets masked)
GATE-->>LB: result
LB-->>TCM: tool result
TCM-->>M: tool response (processed like any tool)
end
The key properties this path guarantees:
- HITL happens first, the network call second. The upstream external server is not contacted until after approval - a declined call never leaves the machine.
- Approved once, not twice. The chat dialog (step 3) is the only prompt; the server-side gate is on the path (step 7) but skips because the caller is the self-loopback client.
- The result is processed identically. After the upstream
CallToolResultreturns, it flows back through the sameAgentLoopManagerround as any built-in tool - same tool-result event, same conversation history, same observability spans.
The same proxied tool called by an external MCP client instead of on-device chat takes the other gate: step 3's dialog is replaced by the server-side elicitation in McpServerHitlToolGate (step 6 is no longer loopback, so it does not skip), and steps 8-13 are identical. Either way the upstream call is reached only after an explicit approval.
Loopback de-duplication¶
Agentic Chat reaches the built-in server's tools through a self-loopback MCP client. Without care, such a call would be gated twice - once by the chat dialog and again by the server elicitation. McpServerHitlToolGate compares the caller's client info against McpClientService.selfLoopbackServerName() and, on a match, skips the elicitation and delegates immediately. The user is asked exactly once, by the chat dialog. External clients do not match the loopback name, so they are always gated by the server elicitation.
Why fail-safe matters¶
Approval is a deny-by-default gate: the call runs only on an explicit, affirmative human ACCEPT. Every other path - no answer, a declined dialog, an incapable client, a dropped connection - resolves to not run. This is deliberate: a HITL tool is one a human decided is consequential enough to confirm, so the safe failure is to withhold execution, never to assume consent.
How it relates to risk¶
HITL and the L0-L5 risk model reinforce each other:
- Defaults track risk. When you author a tool, the approval mode defaults to
DISABLEDatL0andREQUIREDaboveL0- the more capable the tool, the more it asks by default. Lowering that protection prompts a confirmation. - Approval lowers displayed risk. Marking a re-exposed external tool HITL also lowers its composed risk by one band (
McpToolRiskComposer.applyHitlMitigation, floored atL1) - a human gating every call genuinely reduces exposure. Built-in tools whose spec requires approval get the same one-band credit, shown as a dual chip (L5 → L4): the inherent level keeps driving sandbox permissions and the audit log, the effective level reflects the mandatory gate. The credit is conditional - a tool whose description trips the poisoning scanner keeps its inherent level, since a deceptive description undermines the very approval dialog the credit relies on. See MCP Server Safety → Composed risk and HITL mitigation.
The risk level is the advice; HITL is the enforcement that makes a high-risk tool safe to keep within reach.
Where it surfaces¶
| Surface | What you set / see | Reference |
|---|---|---|
| Tool Studio → Sandbox & Capabilities | The Human-in-the-loop mode (Required / Disabled) and an optional approval prompt for a tool you author | Human-in-the-Loop feature |
| Composed Tools drawer → HITL column | Per-tool approval for a re-exposed external tool (attaches the REQUIRED flag to its wrapper) |
MCP Server Proxy |
| Agentic Chat | The Approve / Decline dialog when a gated tool is called | Tutorial 11 - Approve a Tool in Chat |
| MCP Inspector → Elicitation | The approval card an external client would see | MCP Inspector |
Further reading¶
- Human-in-the-Loop Approval (feature) - the user-facing how-to
- Tutorial 11 - Approve a Tool in Chat - end-to-end walkthrough
- MCP Server Safety - risk scoring for external tools that this gate protects
- Application - where the approval gate sits in the runtime layers and chat advisor chain
- AI Agent Observability - how approve/decline decisions surface (
mcp.hitl.decisioncounter,hitl.*logs)