Skip to content

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.

#[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.

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.

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.

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.

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.

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.

  • ToolApprovalHandler::approve runs before every tool call; execution proceeds only on Approved / ApprovedForSession.
  • DeniedWithReason blocks the call and lets the model read why and adapt.
  • ApprovedForSession / DeniedAlways are sticky for the agent’s lifetime.
  • Register with .with_approval_handler(...); use .with_lifecycle_hook(...) for observe-only.
  • Combine with ToolPolicy for defense in depth.