Human-in-the-Loop Approvals
Some tool calls shouldn’t happen automatically: deleting files, running shell
commands, sending money, mutating a production database. Kova lets you insert a
gate that is consulted before every tool execution — the
ToolApprovalHandler.
The trait
Section titled “The trait”#[async_trait]pub trait ToolApprovalHandler: Send + Sync { async fn approve(&self, tool_name: &str, args: &Value) -> ApprovalDecision;}The agent calls approve with the tool’s name and JSON arguments; the tool runs
only if you return Approved or ApprovedForSession.
The five decisions
Section titled “The five decisions”pub enum ApprovalDecision { Approved, // allow this call; ask again next time ApprovedForSession, // allow this call and all future calls of this tool Denied, // block this call; ask again next time DeniedWithReason(String), // block this call; send `reason` back to the model DeniedAlways, // block this call and all future calls of this tool}Approved/Denied— a one-off decision for this call only.ApprovedForSession/DeniedAlways— a sticky decision for the agent’s lifetime. The handler is not consulted again for that tool; Kova remembers.DeniedWithReason(String)— the interesting one. It blocks the call and passes your reason back to the model as the tool’s (error) result, so the model can adapt: “user said: use the staging database instead.”
A denied call surfaces to the model as an error ToolResult, so the model can
try something else rather than the turn simply failing.
Example 1: interactive CLI prompt
Section titled “Example 1: interactive CLI prompt”Ask the human to approve each call from the terminal:
use kova_sdk::prelude::*;use async_trait::async_trait;use serde_json::Value;use std::io::{self, Write};
struct CliApproval;
#[async_trait]impl ToolApprovalHandler for CliApproval { async fn approve(&self, tool_name: &str, args: &Value) -> ApprovalDecision { print!("\nAgent wants to run `{tool_name}` with {args}. Allow? [y/N/always] "); io::stdout().flush().ok();
let mut line = String::new(); io::stdin().read_line(&mut line).ok(); match line.trim().to_lowercase().as_str() { "y" | "yes" => ApprovalDecision::Approved, "always" => ApprovalDecision::ApprovedForSession, _ => ApprovalDecision::DeniedWithReason( "the user declined this action".into(), ), } }}
let agent = AgentBuilder::new() .provider(provider) .tool(Arc::new(DeleteFile)) .with_approval_handler(Arc::new(CliApproval)) .build()?;Now every tool call pauses for a y/N/always prompt before running.
Example 2: policy-based allow/deny
Section titled “Example 2: policy-based allow/deny”No human in the loop — encode rules. Auto-approve reads, require nothing for safe tools, and permanently block writes outside a directory:
struct PolicyApproval;
#[async_trait]impl ToolApprovalHandler for PolicyApproval { async fn approve(&self, tool_name: &str, args: &Value) -> ApprovalDecision { match tool_name { // read-only tools: always fine, and don't ask again "read_file" | "list_dir" | "search" => ApprovalDecision::ApprovedForSession,
// writes: only inside /srv/project "write_file" | "edit_file" => { let path = args["path"].as_str().unwrap_or(""); if path.starts_with("/srv/project/") { ApprovalDecision::Approved } else { ApprovalDecision::DeniedWithReason( format!("writes are only allowed under /srv/project; {path} is outside"), ) } }
// shell: never "shell" => ApprovalDecision::DeniedAlways,
_ => ApprovalDecision::Approved, } }}Because DeniedWithReason feeds the reason back to the model, the agent will
often self-correct — here, retrying the write inside /srv/project.
How it interacts with the loop
Section titled “How it interacts with the loop”The approval handler is consulted for each tool call the model requests,
before execution. When tools run concurrently, the handler is called for each
(implementations must be Send + Sync — hence the async_trait). Sticky
decisions (ApprovedForSession / DeniedAlways) are enforced by the agent for
its lifetime, so you’re asked at most once per tool for those.
Note that DeniedAlways persisting beyond the agent’s lifetime is your
responsibility — if you want a rule to survive restarts, store it yourself and
consult it inside approve.
Observing without gating: ToolLifecycleHook
Section titled “Observing without gating: ToolLifecycleHook”If you only want to watch tool calls (logging, metrics, a progress spinner) —
not gate them — implement ToolLifecycleHook instead and register it with
.with_lifecycle_hook(...). It observes tool start and end and is skipped for
denied calls. Use an approval handler to decide, a lifecycle hook to
observe.
Approvals vs. ToolPolicy
Section titled “Approvals vs. ToolPolicy”These are complementary:
ToolPolicy(built-in tools) — static confinement decided at build time (this workspace, these protected paths, this timeout).ToolApprovalHandler— a dynamic per-call gate decided at runtime, with the actual arguments in hand.
For a coding agent that can touch the filesystem, use both: a ToolPolicy to
sandbox the workspace, plus an approval handler so a human okays each write.
Key takeaways
Section titled “Key takeaways”ToolApprovalHandler::approveruns before every tool call; execution proceeds only onApproved/ApprovedForSession.DeniedWithReasonblocks the call and lets the model read why and adapt.ApprovedForSession/DeniedAlwaysare sticky for the agent’s lifetime.- Register with
.with_approval_handler(...); use.with_lifecycle_hook(...)for observe-only. - Combine with
ToolPolicyfor defense in depth.