Overview
Human-in-the-loop (HITL) means requiring human approval before agents perform certain actions. This is critical for:- High-stakes operations: Deleting data, making purchases, sending communications
- Compliance requirements: Regulated industries requiring human oversight
- Safety guardrails: Preventing unintended consequences in production
- Gradual trust building: Starting with oversight, removing it as confidence grows
What’s in this guide:
- The Core Pattern — Basic HITL implementation (start here)
- Decision Types — Approve, edit, or reject with feedback
- Approval Mechanisms — CLI, web dashboard, Slack, conditional, and more
- Practical Examples — Real-world deployment, support, and data analysis use cases
- Advanced Patterns — Batch approvals, budgets, time-based rules, audit trails
- Best Practices — Production-ready tips for scaling HITL systems
The Core Pattern
Instead of passing a function directly to an agent, wrap it with approval logic:from agentica import spawn
# Original function
def delete_file(path: str) -> None:
os.remove(path)
# Wrapped version with approval
def delete_file_with_approval(path: str) -> None:
# Ask for approval however you want
response = input(f"Delete {path}? (yes/no): ")
if response.lower() != 'yes':
raise PermissionError(f"User denied deletion of {path}")
# If approved, call the original function
os.remove(path)
# Pass the wrapped version to the agent
agent = await spawn(premise="You are a file organizer")
await agent.call(None, "Clean up the temp directory", delete_file=delete_file_with_approval)
import { spawn } from '@symbolica/agentica';
import * as readline from 'readline';
// Original function
function deleteFile(path: string): void {
fs.unlinkSync(path);
}
// Wrapped version with approval
async function deleteFileWithApproval(path: string): Promise<void> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const answer = await new Promise<string>(resolve => {
rl.question(`Delete ${path}? (yes/no): `, resolve);
});
rl.close();
if (answer.toLowerCase() !== 'yes') {
throw new Error(`User denied deletion of ${path}`);
}
// If approved, call the original function
fs.unlinkSync(path);
}
// Pass the wrapped version to the agent
const agent = await spawn({ premise: "You are a file organizer" });
await agent.call("Clean up the temp directory", { deleteFile: deleteFileWithApproval });
The agent sees the approval prompt as part of the function’s behavior. If you deny the action, the agent receives an exception and can adapt accordingly.
Decision Types
When reviewing an action, you have three typical options:| Decision | Description | When to Use | Agent Response |
|---|---|---|---|
| Approve | Execute the action exactly as proposed | Action is correct and safe | Proceeds with execution |
| Edit | Modify arguments before executing | Action is almost right but needs tweaking | Executes with your changes |
| Reject | Deny the action with feedback | Action is wrong or unsafe | Sees your feedback and adapts strategy |
Approve: Execute As-Is
Approve: Execute As-Is
response = input(f"Approve action? (yes/no): ")
if response.lower() == 'yes':
return original_function(*args, **kwargs)
else:
raise PermissionError("Action denied")
const response = await getInput("Approve action? (yes/no): ");
if (response.toLowerCase() === 'yes') {
return originalFunction(...args);
} else {
throw new Error("Action denied");
}
Edit: Modify Before Executing
Edit: Modify Before Executing
Modify the agent’s proposed arguments before executing. This is useful when the action is almost right but needs adjustment:
def send_email_with_editable_approval(to: str, subject: str, body: str) -> str:
print()
print(f"📧 Agent wants to send email:")
print(f" To: {to}")
print(f" Subject: {subject}")
print(f" Body: {body[:100]}...")
print()
response = input(" Action? (approve/edit/reject): ")
if response == 'approve':
return send_email(to, subject, body)
elif response == 'edit':
# Let human modify the arguments
new_to = input(f" To [{to}]: ") or to
new_subject = input(f" Subject [{subject}]: ") or subject
new_body = input(f" Body (press enter to keep current): ") or body
print(f"Executing with modified arguments")
return send_email(new_to, new_subject, new_body)
else:
raise PermissionError("Email sending denied")
async function sendEmailWithEditableApproval(
to: string,
subject: string,
body: string
): Promise<string> {
console.log('\n📧 Agent wants to send email:');
console.log(` To: ${to}`);
console.log(` Subject: ${subject}`);
console.log(` Body: ${body.slice(0, 100)}...`);
const action = await getInput('\n Action? (approve/edit/reject): ');
if (action === 'approve') {
return sendEmail(to, subject, body);
} else if (action === 'edit') {
// Let human modify the arguments
const newTo = await getInput(` To [${to}]: `) || to;
const newSubject = await getInput(` Subject [${subject}]: `) || subject;
const newBody = await getInput(` Body (enter to keep): `) || body;
console.log('Executing with modified arguments');
return sendEmail(newTo, newSubject, newBody);
} else {
throw new Error('Email sending denied');
}
}
Reject with Feedback
Reject with Feedback
Deny the action but provide feedback that helps the agent understand why and try a different approach:
def execute_sql_with_feedback(query: str) -> list:
print(f"\nAgent wants to execute SQL:")
print(f" Query: {query[:100]}...")
response = input("\n Approve? (yes/no): ")
if response.lower() == 'yes':
return run_sql_query(query)
else:
# Provide specific feedback to help the agent
feedback = input(" Reason for rejection: ")
raise PermissionError(
f"SQL execution denied. Feedback: {feedback}. "
f"Please revise the query and try again."
)
# The agent sees this error message and can adapt its approach
async function executeSqlWithFeedback(query: string): Promise<any[]> {
console.log('\nAgent wants to execute SQL:');
console.log(` Query: ${query.slice(0, 100)}...`);
const response = await getInput('\n Approve? (yes/no): ');
if (response.toLowerCase() === 'yes') {
return runSqlQuery(query);
} else {
// Provide specific feedback to help the agent
const feedback = await getInput(' Reason for rejection: ');
throw new Error(
`SQL execution denied. Feedback: ${feedback}. ` +
`Please revise the query and try again.`
);
// The agent sees this error message and can adapt its approach
}
}
When you raise an exception with a descriptive message, the agent sees that message and can adjust its strategy. For example, if you reject a query because “must use READ ONLY transaction,” the agent might then add that to its next attempt.
Approval Mechanisms
How you ask for approval depends on your application context. Here are practical patterns:Terminal/CLI Approval
Terminal/CLI Approval
Perfect for development, scripts, and command-line tools:
def with_cli_approval(func, action_description: str):
"""Decorator that adds CLI approval to any function."""
def wrapper(*args, **kwargs):
print(f"\n🤖 Agent wants to: {action_description}")
print(f" Args: {args}, Kwargs: {kwargs}")
response = input(" Approve? (yes/no): ")
if response.lower() != 'yes':
raise PermissionError(f"User denied: {action_description}")
return func(*args, **kwargs)
return wrapper
# Use it
send_email = with_cli_approval(
original_send_email,
"send an email"
)
agent = await spawn(premise="You are an assistant")
await agent.call(None, "Email the team about the meeting", send_email=send_email)
import * as readline from 'readline';
function withCLIApproval<T extends (...args: any[]) => any>(
func: T,
actionDescription: string
): T {
return (async (...args: any[]) => {
console.log(`\n🤖 Agent wants to: ${actionDescription}`);
console.log(` Args: ${JSON.stringify(args)}`);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const answer = await new Promise<string>(resolve => {
rl.question(' Approve? (yes/no): ', resolve);
});
rl.close();
if (answer.toLowerCase() !== 'yes') {
throw new Error(`User denied: ${actionDescription}`);
}
return func(...args);
}) as T;
}
// Use it
const sendEmail = withCLIApproval(
originalSendEmail,
"send an email"
);
const agent = await spawn({ premise: "You are an assistant" });
await agent.call("Email the team about the meeting", { sendEmail });
Browser Alert/Confirm
Browser Alert/Confirm
For web applications running in the browser:
function withBrowserApproval<T extends (...args: any[]) => any>(
func: T,
actionDescription: string
): T {
return ((...args: any[]) => {
const confirmed = window.confirm(
`Agent wants to ${actionDescription}\n\n` +
`Details: ${JSON.stringify(args, null, 2)}\n\n` +
`Allow this action?`
);
if (!confirmed) {
throw new Error(`User denied: ${actionDescription}`);
}
return func(...args);
}) as T;
}
// Use it with Slack integration
const postMessage = withBrowserApproval(
slackClient.chat.postMessage.bind(slackClient.chat),
"post a Slack message"
);
const agent = await spawn({ premise: "You are a Slack assistant" });
await agent.call(
"Send a message to #engineering about the deploy",
{ postMessage }
);
Web Dashboard Approval
Web Dashboard Approval
For production systems with approval workflows, you may want approvals to go through a proper web interface where managers can review requests, see context, and make informed decisions. This is especially useful when:
- The person approving isn’t at their terminal
- You need a record of who approved what and when
- Multiple people might need to approve the same action
- You want to batch approval requests for review
import requests
import time
def with_dashboard_approval(func, action_description: str, dashboard_url: str):
"""Request approval via web dashboard."""
def wrapper(*args, **kwargs):
# Create approval request
response = requests.post(
f"{dashboard_url}/api/approvals",
json={"action": action_description, "args": list(args)}
)
request_id = response.json()["id"]
print(f"⏳ Waiting for approval at {dashboard_url}/approvals/{request_id}")
# Poll for approval
while True:
status = requests.get(f"{dashboard_url}/api/approvals/{request_id}").json()
if status["approved"]:
print(f"✅ Action approved")
return func(*args, **kwargs)
elif status["denied"]:
raise PermissionError(f"Action denied: {status['reason']}")
time.sleep(2) # Poll every 2 seconds
return wrapper
function withDashboardApproval<T extends (...args: any[]) => any>(
func: T,
actionDescription: string,
dashboardUrl: string
): T {
return (async (...args: any[]) => {
// Create approval request
const response = await fetch(`${dashboardUrl}/api/approvals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: actionDescription, args })
});
const { id: requestId } = await response.json();
console.log(`⏳ Waiting for approval at ${dashboardUrl}/approvals/${requestId}`);
// Poll for approval
while (true) {
const status = await (await fetch(`${dashboardUrl}/api/approvals/${requestId}`)).json();
if (status.approved) {
console.log('✅ Action approved');
return func(...args);
} else if (status.denied) {
throw new Error(`Action denied: ${status.reason}`);
}
await new Promise(resolve => setTimeout(resolve, 2000)); // Poll every 2s
}
}) as T;
}
See the Customer Support example below for a complete production implementation using this pattern.
Slack/Teams Approval
Slack/Teams Approval
Request approval via chat platforms where your team already is:
from slack_sdk import WebClient
def with_slack_approval(func, action_description: str, slack_client: WebClient, channel: str):
"""Request approval via Slack message with buttons."""
def wrapper(*args, **kwargs):
# Send approval request with interactive buttons
response = slack_client.chat_postMessage(
channel=channel,
text=f"🤖 Agent approval needed: {action_description}",
blocks=[
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*Agent wants to:* {action_description}\n*Details:* ```{args}```"
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "Approve"},
"style": "primary",
"action_id": "approve_action"
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Deny"},
"style": "danger",
"action_id": "deny_action"
}
]
}
]
)
# Wait for button click (would need webhook handler in real implementation)
# This is simplified - production needs proper Slack event handling
approval_status = wait_for_slack_response(response['ts'])
if approval_status != 'approved':
raise PermissionError(f"Action denied via Slack")
return func(*args, **kwargs)
return wrapper
import { WebClient } from '@slack/web-api';
function withSlackApproval<T extends (...args: any[]) => any>(
func: T,
actionDescription: string,
slackClient: WebClient,
channel: string
): T {
return (async (...args: any[]) => {
// Send approval request with interactive buttons
const response = await slackClient.chat.postMessage({
channel,
text: `🤖 Agent approval needed: ${actionDescription}`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Agent wants to:* ${actionDescription}\n*Details:* \`\`\`${JSON.stringify(args)}\`\`\``
}
},
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: 'Approve' },
style: 'primary',
action_id: 'approve_action'
},
{
type: 'button',
text: { type: 'plain_text', text: 'Deny' },
style: 'danger',
action_id: 'deny_action'
}
]
}
]
});
// Wait for button click (requires webhook handler in production)
const approvalStatus = await waitForSlackResponse(response.ts!);
if (approvalStatus !== 'approved') {
throw new Error('Action denied via Slack');
}
return func(...args);
}) as T;
}
Conditional Approval
Conditional Approval
Only require approval for high-risk operations:
def with_conditional_approval(func, should_require_approval):
"""Only require approval when condition is met."""
def wrapper(*args, **kwargs):
# Check if this specific call needs approval
if should_require_approval(*args, **kwargs):
response = input(f"⚠️ High-risk operation detected. Approve? (yes/no): ")
if response.lower() != 'yes':
raise PermissionError("User denied high-risk operation")
return func(*args, **kwargs)
return wrapper
# Only approve transfers over $10,000
def needs_approval_for_large_transfer(amount: float, recipient: str) -> bool:
return amount > 10000
transfer_money = with_conditional_approval(
bank.transfer,
needs_approval_for_large_transfer
)
agent = await spawn(premise="Financial assistant")
await agent.call(None, "Pay the invoices", transfer_money=transfer_money)
function withConditionalApproval<T extends (...args: any[]) => any>(
func: T,
shouldRequireApproval: (...args: any[]) => boolean
): T {
return (async (...args: any[]) => {
if (shouldRequireApproval(...args)) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const answer = await new Promise<string>(resolve => {
rl.question('⚠️ High-risk operation detected. Approve? (yes/no): ', resolve);
});
rl.close();
if (answer.toLowerCase() !== 'yes') {
throw new Error('User denied high-risk operation');
}
}
return func(...args);
}) as T;
}
// Only approve transfers over $10,000
function needsApprovalForLargeTransfer(amount: number, recipient: string): boolean {
return amount > 10000;
}
const transferMoney = withConditionalApproval(
bank.transfer.bind(bank),
needsApprovalForLargeTransfer
);
const agent = await spawn({ premise: "Financial assistant" });
await agent.call("Pay the invoices", { transferMoney });
Practical Examples
Example: Deployment Pipeline with HITL
Example: Deployment Pipeline with HITL
An agent that can deploy code, but requires approval for production:
from agentica import spawn
from dataclasses import dataclass
@dataclass
class DeploymentConfig:
environment: str
branch: str
services: list[str]
def deploy_to_environment(config: DeploymentConfig) -> str:
"""Actually perform the deployment."""
# Deployment logic here
return f"Deployed {config.services} to {config.environment}"
def deploy_with_approval(config: DeploymentConfig) -> str:
"""Wrapper that requires approval for production."""
if config.environment == "production":
print(f"\n⚠️ PRODUCTION DEPLOYMENT REQUEST")
print(f" Branch: {config.branch}")
print(f" Services: {', '.join(config.services)}")
response = input("\n Deploy to production? (yes/no): ")
if response.lower() != 'yes':
raise PermissionError("Production deployment denied")
print("\n✅ Production deployment approved")
return deploy_to_environment(config)
# Use with agent
agent = await spawn(
premise="You are a DevOps assistant. Deploy services when requested.",
model="openai/gpt-5.2"
)
result = await agent.call(
str,
"Deploy the auth-service from main branch to production",
deploy=deploy_with_approval
)
# Agent will call deploy_with_approval, which prompts for confirmation
import { spawn } from '@symbolica/agentica';
import * as readline from 'readline';
interface DeploymentConfig {
environment: string;
branch: string;
services: string[];
}
function deployToEnvironment(config: DeploymentConfig): string {
// Deployment logic here
return `Deployed ${config.services.join(', ')} to ${config.environment}`;
}
async function deployWithApproval(config: DeploymentConfig): Promise<string> {
if (config.environment === 'production') {
console.log('\n⚠️ PRODUCTION DEPLOYMENT REQUEST');
console.log(` Branch: ${config.branch}`);
console.log(` Services: ${config.services.join(', ')}`);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const answer = await new Promise<string>(resolve => {
rl.question('\n Deploy to production? (yes/no): ', resolve);
});
rl.close();
if (answer.toLowerCase() !== 'yes') {
throw new Error('Production deployment denied');
}
console.log('\n✅ Production deployment approved');
}
return deployToEnvironment(config);
}
// Use with agent
const agent = await spawn({
premise: "You are a DevOps assistant. Deploy services when requested.",
model: "openai/gpt-5.2"
});
const result = await agent.call<string>(
"Deploy the auth-service from main branch to production",
{ deploy: deployWithApproval }
);
// Agent will call deployWithApproval, which prompts for confirmation
Example: Customer Support with Refund Approval
Example: Customer Support with Refund Approval
Agent handles support tickets but requires approval for refunds:
from agentica import spawn
class SupportTools:
def __init__(self, approval_dashboard_url: str):
self.dashboard_url = approval_dashboard_url
def send_canned_response(self, ticket_id: str, response_type: str) -> str:
"""Automated responses don't need approval."""
return f"Sent {response_type} response to ticket {ticket_id}"
def issue_refund(self, ticket_id: str, amount: float, reason: str) -> str:
"""Refunds require approval via dashboard."""
# Create approval request
response = requests.post(
f"{self.dashboard_url}/api/refund-approvals",
json={
"ticket_id": ticket_id,
"amount": amount,
"reason": reason
}
)
approval_id = response.json()["id"]
print(f"💰 Refund approval needed: ${amount} for ticket {ticket_id}")
print(f" Review at: {self.dashboard_url}/approvals/{approval_id}")
# Wait for approval
while True:
status = requests.get(
f"{self.dashboard_url}/api/refund-approvals/{approval_id}"
).json()
if status["approved"]:
# Actually process the refund
return f"Refunded ${amount} to customer (ticket {ticket_id})"
elif status["denied"]:
raise PermissionError(f"Refund denied: {status['denial_reason']}")
time.sleep(3)
# Use with support agent
tools = SupportTools("https://support-dashboard.company.com")
agent = await spawn(
premise="""You are a customer support agent.
You can send canned responses for common issues.
For refund requests, use issue_refund - it will be reviewed by a manager."""
)
result = await agent.call(
str,
"Handle ticket #12345 - customer unhappy with product quality, requesting refund of $49.99",
send_response=tools.send_canned_response,
issue_refund=tools.issue_refund
)
import { spawn } from '@symbolica/agentica';
class SupportTools {
constructor(private dashboardUrl: string) {}
sendCannedResponse(ticketId: string, responseType: string): string {
// Automated responses don't need approval
return `Sent ${responseType} response to ticket ${ticketId}`;
}
async issueRefund(ticketId: string, amount: number, reason: string): Promise<string> {
// Create approval request
const response = await fetch(`${this.dashboardUrl}/api/refund-approvals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ticketId, amount, reason })
});
const { id: approvalId } = await response.json();
console.log(`💰 Refund approval needed: $${amount} for ticket ${ticketId}`);
console.log(` Review at: ${this.dashboardUrl}/approvals/${approvalId}`);
// Wait for approval
while (true) {
const statusResponse = await fetch(
`${this.dashboardUrl}/api/refund-approvals/${approvalId}`
);
const status = await statusResponse.json();
if (status.approved) {
return `Refunded $${amount} to customer (ticket ${ticketId})`;
} else if (status.denied) {
throw new Error(`Refund denied: ${status.denialReason}`);
}
await new Promise(resolve => setTimeout(resolve, 3000));
}
}
}
// Use with support agent
const tools = new SupportTools('https://support-dashboard.company.com');
const agent = await spawn({
premise: `You are a customer support agent.
You can send canned responses for common issues.
For refund requests, use issueRefund - it will be reviewed by a manager.`
});
const result = await agent.call<string>(
"Handle ticket #12345 - customer unhappy with product quality, requesting refund of $49.99",
{
sendResponse: tools.sendCannedResponse.bind(tools),
issueRefund: tools.issueRefund.bind(tools)
}
);
Example: Data Analysis with Destructive Action Gates
Example: Data Analysis with Destructive Action Gates
Agent can query data freely, but needs approval for modifications:
from agentica import spawn
import pandas as pd
class DatabaseTools:
def __init__(self, db_connection):
self.db = db_connection
def query_data(self, sql: str) -> dict:
"""Read-only queries are always allowed."""
if not sql.strip().upper().startswith('SELECT'):
raise ValueError("Only SELECT queries allowed in query_data")
result = pd.read_sql(sql, self.db)
return result.to_dict()
def delete_records(self, table: str, condition: str) -> str:
"""Deletions require approval."""
# Show what would be deleted
preview_sql = f"SELECT * FROM {table} WHERE {condition}"
preview = pd.read_sql(preview_sql, self.db)
print(f"\n⚠️ DELETION REQUEST")
print(f" Table: {table}")
print(f" Condition: {condition}")
print(f" Rows to be deleted: {len(preview)}")
print(f"\n Preview:")
print(preview.head().to_string())
response = input(f"\n Delete these {len(preview)} rows? (yes/no): ")
if response.lower() != 'yes':
raise PermissionError("Deletion denied")
# Perform deletion
self.db.execute(f"DELETE FROM {table} WHERE {condition}")
return f"Deleted {len(preview)} rows from {table}"
# Use with data analysis agent
db_tools = DatabaseTools(db_connection)
agent = await spawn(
premise="You are a data analyst. Query data to answer questions."
)
await agent.call(
None,
"Find and delete all test users created before 2024",
query_data=db_tools.query_data,
delete_records=db_tools.delete_records
)
# Agent can query freely, but deletions prompt for approval
import { spawn } from '@symbolica/agentica';
import * as readline from 'readline';
class DatabaseTools {
constructor(private db: any) {}
queryData(sql: string): object {
if (!sql.trim().toUpperCase().startsWith('SELECT')) {
throw new Error("Only SELECT queries allowed in queryData");
}
return this.db.query(sql);
}
async deleteRecords(table: string, condition: string): Promise<string> {
// Show what would be deleted
const preview = this.db.query(
`SELECT * FROM ${table} WHERE ${condition}`
);
console.log('\n⚠️ DELETION REQUEST');
console.log(` Table: ${table}`);
console.log(` Condition: ${condition}`);
console.log(` Rows to be deleted: ${preview.length}`);
console.log(`\n Preview:`);
console.log(preview.slice(0, 5));
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const answer = await new Promise<string>(resolve => {
rl.question(`\n Delete these ${preview.length} rows? (yes/no): `, resolve);
});
rl.close();
if (answer.toLowerCase() !== 'yes') {
throw new Error('Deletion denied');
}
// Perform deletion
this.db.execute(`DELETE FROM ${table} WHERE ${condition}`);
return `Deleted ${preview.length} rows from ${table}`;
}
}
// Use with data analysis agent
const dbTools = new DatabaseTools(dbConnection);
const agent = await spawn({
premise: "You are a data analyst. Query data to answer questions."
});
await agent.call(
"Find and delete all test users created before 2024",
{
queryData: dbTools.queryData.bind(dbTools),
deleteRecords: dbTools.deleteRecords.bind(dbTools)
}
);
// Agent can query freely, but deletions prompt for approval
Advanced Patterns
Batch Approvals
Batch Approvals
When an agent wants to perform multiple actions at once, you can collect them all, review them together, then decide on each one.How it works:Example: Agent adapts based on approval results
- When the agent calls a wrapped function, instead of executing immediately, the call is queued and a placeholder like
"PENDING_APPROVAL_1"is returned to the agent - The agent continues executing and may queue multiple actions
- After the agent finishes, you call
review_and_execute()to see all pending actions at once - You can approve all, deny all, or handle each individually
- The actual functions execute based on your decisions
def create_batch_approver(*funcs_with_names):
"""
Create wrapper that shows all pending actions for review.
funcs_with_names: list of (name, function) tuples
"""
pending_actions = []
def make_wrapper(name, func):
def wrapper(*args, **kwargs):
# Collect action for later approval
action_id = len(pending_actions) + 1
pending_actions.append({
'name': name,
'func': func,
'args': args,
'kwargs': kwargs,
'id': action_id
})
# Return marker that agent can see
return f"PENDING_APPROVAL_{action_id}"
return wrapper
# Create wrapped versions
wrapped_funcs = {
name: make_wrapper(name, func)
for name, func in funcs_with_names
}
# Store results for wait_for_approvals to return
approval_results = {}
approval_complete = {'done': False}
def wait_for_approvals():
"""
Agent calls this to block until human reviews all pending actions.
Returns dict mapping action IDs to their results.
"""
import time
print("\n[Agent is waiting for approval of pending actions...]")
# Block until review_and_execute is called
while not approval_complete['done']:
time.sleep(0.1)
# Return results to agent
approval_complete['done'] = False # Reset for next batch
return approval_results.copy()
def review_and_execute():
"""Review all pending actions and execute based on decisions."""
if not pending_actions:
return []
print(f"\nAgent wants to perform {len(pending_actions)} actions:")
# Show all actions
for i, action in enumerate(pending_actions, 1):
print(f"\n{i}. {action['name']}")
print(f" Args: {action['args']}")
print(f" Kwargs: {action['kwargs']}")
# Get batch decision
print(f"\nOptions: 'all' (approve all), 'none' (deny all), 'review' (individual), 'edit N' (edit action N)")
batch_decision = input("Decision: ").strip().lower()
results = []
if batch_decision == 'all':
# Approve all
for action in pending_actions:
result = action['func'](*action['args'], **action['kwargs'])
results.append(result)
approval_results[action['id']] = result
print(f"✓ Executed {action['name']}: {result}")
elif batch_decision == 'none':
# Deny all
feedback = input("Reason for denying all: ")
for action in pending_actions:
rejection = f"REJECTED: {feedback}"
results.append(rejection)
approval_results[action['id']] = rejection
print(f"✗ Denied all {len(pending_actions)} actions")
elif batch_decision.startswith('edit '):
# Edit specific action
try:
idx = int(batch_decision.split()[1]) - 1
if 0 <= idx < len(pending_actions):
action = pending_actions[idx]
print(f"\nEditing action {idx + 1}: {action['name']}")
print(f" Current args: {action['args']}")
# Simple example: modify first positional arg
if action['args']:
new_arg = input(f" {action['args'][0]} -> ")
if new_arg:
modified_args = (new_arg,) + action['args'][1:]
action['args'] = modified_args
print(f" Updated. Run review_and_execute() again to approve.")
return [] # Return empty, user needs to review again
else:
print(f"Invalid index. Use 1-{len(pending_actions)}")
return []
except (ValueError, IndexError):
print("Invalid edit command. Use 'edit N' where N is the action number.")
return []
else: # 'review' or default - individual review
for i, action in enumerate(pending_actions, 1):
print(f"\n--- Action {i}/{len(pending_actions)}: {action['name']} ---")
print(f" Args: {action['args']}")
decision = input(" Decision (approve/reject/skip): ").strip().lower()
if decision == 'approve':
result = action['func'](*action['args'], **action['kwargs'])
results.append(result)
approval_results[action['id']] = result
print(f" ✓ Executed: {result}")
elif decision == 'reject':
feedback = input(" Reason: ")
rejection = f"REJECTED: {feedback}"
results.append(rejection)
approval_results[action['id']] = rejection
print(f" ✗ Rejected")
else: # skip
skipped = "SKIPPED"
results.append(skipped)
approval_results[action['id']] = skipped
print(f" - Skipped")
pending_actions.clear()
approval_complete['done'] = True # Signal to wait_for_approvals
return results
wrapped_funcs['_review_and_execute'] = review_and_execute
wrapped_funcs['wait_for_approvals'] = wait_for_approvals
return wrapped_funcs
# Usage example - Agent waits for approval
from agentica import spawn
def delete_user(user_id: str) -> str:
return f"Deleted user {user_id}"
def send_notification(user_id: str, message: str) -> str:
return f"Sent '{message}' to {user_id}"
# Create batch-approved versions
tools = create_batch_approver(
('delete_user', delete_user),
('send_notification', send_notification)
)
agent = await spawn(premise="You are a user admin")
# Agent queues actions then waits for approval
await agent.call(
None,
"""Delete user 123 and notify user 456 about it.
After queuing these actions, call wait_for_approvals() to get the actual results.""",
delete_user=tools['delete_user'],
send_notification=tools['send_notification'],
wait_for_approvals=tools['wait_for_approvals']
)
# What happens:
# 1. Agent calls delete_user(123) → gets "PENDING_APPROVAL_1"
# 2. Agent calls send_notification(456, "...") → gets "PENDING_APPROVAL_2"
# 3. Agent calls wait_for_approvals() → BLOCKS waiting for human
# 4. Human reviews in another terminal/thread with tools['_review_and_execute']()
# 5. wait_for_approvals() returns {1: "Deleted user 123", 2: "Sent '...' to 456"}
# 6. Agent can now see which actions succeeded vs were rejected
function createBatchApprover(
funcsWithNames: Array<[string, Function]>
): Record<string, Function> {
const pendingActions: Array<{
name: string;
func: Function;
args: any[];
id: number;
}> = [];
function makeWrapper(name: string, func: Function) {
return (...args: any[]) => {
// Collect action for later approval
const actionId = pendingActions.length + 1;
pendingActions.push({ name, func, args, id: actionId });
return `PENDING_APPROVAL_${actionId}`;
};
}
// Create wrapped versions
const wrappedFuncs: Record<string, Function> = {};
for (const [name, func] of funcsWithNames) {
wrappedFuncs[name] = makeWrapper(name, func);
}
// Store results for waitForApprovals to return
const approvalResults: Record<number, any> = {};
let approvalComplete = false;
async function waitForApprovals(): Promise<Record<number, any>> {
console.log('\n[Agent is waiting for approval of pending actions...]');
// Block until reviewAndExecute is called
while (!approvalComplete) {
await new Promise(resolve => setTimeout(resolve, 100));
}
// Return results to agent
approvalComplete = false; // Reset for next batch
return { ...approvalResults };
}
async function reviewAndExecute(): Promise<any[]> {
if (pendingActions.length === 0) return [];
console.log(`\nAgent wants to perform ${pendingActions.length} actions:`);
// Show all actions
pendingActions.forEach((action, i) => {
console.log(`\n${i + 1}. ${action.name}`);
console.log(` Args: ${action.args}`);
});
// Get batch decision
console.log(`\nOptions: 'all' (approve all), 'none' (deny all), 'review' (individual), 'edit N' (edit action N)`);
const batchDecision = (await getInput('Decision: ')).trim().toLowerCase();
const results: any[] = [];
if (batchDecision === 'all') {
// Approve all
for (const action of pendingActions) {
const result = await action.func(...action.args);
results.push(result);
approvalResults[action.id] = result;
console.log(`✓ Executed ${action.name}: ${result}`);
}
} else if (batchDecision === 'none') {
// Deny all
const feedback = await getInput('Reason for denying all: ');
for (const action of pendingActions) {
const rejection = `REJECTED: ${feedback}`;
results.push(rejection);
approvalResults[action.id] = rejection;
}
console.log(`✗ Denied all ${pendingActions.length} actions`);
} else if (batchDecision.startsWith('edit ')) {
// Edit specific action
try {
const idx = parseInt(batchDecision.split(' ')[1]) - 1;
if (idx >= 0 && idx < pendingActions.length) {
const action = pendingActions[idx];
console.log(`\nEditing action ${idx + 1}: ${action.name}`);
console.log(` Current args: ${action.args}`);
if (action.args.length > 0) {
const newArg = await getInput(` ${action.args[0]} -> `);
if (newArg) {
action.args = [newArg, ...action.args.slice(1)];
console.log(' Updated. Run reviewAndExecute() again to approve.');
}
}
return []; // Return empty, user needs to review again
} else {
console.log(`Invalid index. Use 1-${pendingActions.length}`);
return [];
}
} catch (e) {
console.log("Invalid edit command. Use 'edit N' where N is the action number.");
return [];
}
} else { // 'review' or default - individual review
for (let i = 0; i < pendingActions.length; i++) {
const action = pendingActions[i];
console.log(`\n--- Action ${i + 1}/${pendingActions.length}: ${action.name} ---`);
console.log(` Args: ${action.args}`);
const decision = (await getInput(' Decision (approve/reject/skip): ')).trim().toLowerCase();
if (decision === 'approve') {
const result = await action.func(...action.args);
results.push(result);
approvalResults[action.id] = result;
console.log(` ✓ Executed: ${result}`);
} else if (decision === 'reject') {
const feedback = await getInput(' Reason: ');
const rejection = `REJECTED: ${feedback}`;
results.push(rejection);
approvalResults[action.id] = rejection;
console.log(' ✗ Rejected');
} else { // skip
const skipped = 'SKIPPED';
results.push(skipped);
approvalResults[action.id] = skipped;
console.log(' - Skipped');
}
}
}
pendingActions.length = 0; // Clear
approvalComplete = true; // Signal to waitForApprovals
return results;
}
wrappedFuncs['_reviewAndExecute'] = reviewAndExecute;
wrappedFuncs['waitForApprovals'] = waitForApprovals;
return wrappedFuncs;
}
// Usage example - Agent waits for approval
import { spawn } from '@symbolica/agentica';
function deleteUser(userId: string): string {
return `Deleted user ${userId}`;
}
function sendNotification(userId: string, message: string): string {
return `Sent '${message}' to ${userId}`;
}
// Create batch-approved versions
const tools = createBatchApprover([
['deleteUser', deleteUser],
['sendNotification', sendNotification]
]);
const agent = await spawn({ premise: "You are a user admin" });
// Agent queues actions then waits for approval
await agent.call(
`Delete user 123 and notify user 456 about it.
After queuing these actions, call waitForApprovals() to get the actual results.`,
{
deleteUser: tools['deleteUser'],
sendNotification: tools['sendNotification'],
waitForApprovals: tools['waitForApprovals']
}
);
// What happens:
// 1. Agent calls deleteUser(123) → gets "PENDING_APPROVAL_1"
// 2. Agent calls sendNotification(456, "...") → gets "PENDING_APPROVAL_2"
// 3. Agent calls waitForApprovals() → BLOCKS waiting for human
// 4. Human reviews in another terminal/thread with tools['_reviewAndExecute']()
// 5. waitForApprovals() returns {1: "Deleted user 123", 2: "Sent '...' to 456"}
// 6. Agent can now see which actions succeeded vs were rejected
Key insight: By providing
wait_for_approvals() to the agent, the agent can:- Queue up actions (getting placeholders back like
"PENDING_APPROVAL_1") - Call
wait_for_approvals()to block until human review - Receive a dict mapping action IDs to actual results:
{1: "Deleted user 123", 2: "REJECTED: ..."} - Adapt based on what was approved vs rejected
if "REJECTED" in results[1] to handle denied actions differently.Alternative: If you don’t give the agent wait_for_approvals(), it will receive placeholders and complete without knowing the real outcomes. Useful when the agent doesn’t need to adapt based on approval decisions.You can customize this pattern however you want: web UI for review, role-based approvals, risk-level grouping, timeouts — just code it.# Give agent ability to check what was approved
agent = await spawn(premise="You are a deployment manager")
await agent.call(
str,
"""Deploy services A, B, and C to production.
After queuing deployments, call wait_for_approvals() to see which were approved.
For any rejected deployments, log the reason and continue with approved ones.""",
deploy_service=tools['deploy_service'],
log_message=tools['log_message'],
wait_for_approvals=tools['wait_for_approvals']
)
# Agent might do:
# 1. deploy_service("A") → "PENDING_APPROVAL_1"
# 2. deploy_service("B") → "PENDING_APPROVAL_2"
# 3. deploy_service("C") → "PENDING_APPROVAL_3"
# 4. results = wait_for_approvals() → {1: "Deployed A", 2: "REJECTED: staging failed", 3: "Deployed C"}
# 5. log_message("Services A and C deployed. Service B rejected: staging failed")
#
# Agent adapts: it sees B was rejected and logs accordingly!
// Give agent ability to check what was approved
const agent = await spawn({ premise: "You are a deployment manager" });
await agent.call<string>(
`Deploy services A, B, and C to production.
After queuing deployments, call waitForApprovals() to see which were approved.
For any rejected deployments, log the reason and continue with approved ones.`,
{
deployService: tools['deployService'],
logMessage: tools['logMessage'],
waitForApprovals: tools['waitForApprovals']
}
);
// Agent might do:
// 1. deployService("A") → "PENDING_APPROVAL_1"
// 2. deployService("B") → "PENDING_APPROVAL_2"
// 3. deployService("C") → "PENDING_APPROVAL_3"
// 4. results = waitForApprovals() → {1: "Deployed A", 2: "REJECTED: staging failed", 3: "Deployed C"}
// 5. logMessage("Services A and C deployed. Service B rejected: staging failed")
//
// Agent adapts: it sees B was rejected and logs accordingly!
Approval Budget
Approval Budget
Allow a certain number of operations without approval, then require oversight:
class ApprovalBudget:
def __init__(self, free_operations: int):
self.remaining = free_operations
def with_budget(self, func, action_description: str):
def wrapper(*args, **kwargs):
if self.remaining > 0:
self.remaining -= 1
print(f"✓ Auto-approved ({self.remaining} remaining in budget)")
return func(*args, **kwargs)
# Budget exhausted, require approval
response = input(f"Budget exhausted. Approve '{action_description}'? (yes/no): ")
if response.lower() != 'yes':
raise PermissionError(f"Denied: {action_description}")
return func(*args, **kwargs)
return wrapper
# Allow 5 free API calls, then require approval
budget = ApprovalBudget(free_operations=5)
make_api_call = budget.with_budget(
external_api.call,
"make external API call"
)
class ApprovalBudget {
private remaining: number;
constructor(freeOperations: number) {
this.remaining = freeOperations;
}
withBudget<T extends (...args: any[]) => any>(
func: T,
actionDescription: string
): T {
return (async (...args: any[]) => {
if (this.remaining > 0) {
this.remaining--;
console.log(`✓ Auto-approved (${this.remaining} remaining in budget)`);
return func(...args);
}
// Budget exhausted, require approval
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const answer = await new Promise<string>(resolve => {
rl.question(`Budget exhausted. Approve '${actionDescription}'? (yes/no): `, resolve);
});
rl.close();
if (answer.toLowerCase() !== 'yes') {
throw new Error(`Denied: ${actionDescription}`);
}
return func(...args);
}) as T;
}
}
// Allow 5 free API calls, then require approval
const budget = new ApprovalBudget(5);
const makeApiCall = budget.withBudget(
externalApi.call.bind(externalApi),
"make external API call"
);
Time-Based Approval
Time-Based Approval
Require approval during business hours, allow automation off-hours:
from datetime import datetime
def with_business_hours_approval(func, action_description: str):
def wrapper(*args, **kwargs):
hour = datetime.now().hour
is_business_hours = 9 <= hour < 17
if is_business_hours:
# During work hours, require approval
response = input(f"[Business hours] Approve '{action_description}'? (yes/no): ")
if response.lower() != 'yes':
raise PermissionError(f"Denied: {action_description}")
else:
# Off hours, auto-approve but log
print(f"✓ Auto-approved (off-hours): {action_description}")
return func(*args, **kwargs)
return wrapper
function withBusinessHoursApproval<T extends (...args: any[]) => any>(
func: T,
actionDescription: string
): T {
return (async (...args: any[]) => {
const hour = new Date().getHours();
const isBusinessHours = hour >= 9 && hour < 17;
if (isBusinessHours) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const answer = await new Promise<string>(resolve => {
rl.question(`[Business hours] Approve '${actionDescription}'? (yes/no): `, resolve);
});
rl.close();
if (answer.toLowerCase() !== 'yes') {
throw new Error(`Denied: ${actionDescription}`);
}
} else {
console.log(`✓ Auto-approved (off-hours): ${actionDescription}`);
}
return func(...args);
}) as T;
}
Audit Trail
Audit Trail
Log all approval decisions for compliance:
import json
from datetime import datetime
class AuditedApproval:
def __init__(self, audit_log_path: str):
self.audit_log = audit_log_path
def with_audit(self, func, action_description: str):
def wrapper(*args, **kwargs):
response = input(f"Approve '{action_description}'? (yes/no): ")
approved = response.lower() == 'yes'
# Log the decision
audit_entry = {
"timestamp": datetime.now().isoformat(),
"action": action_description,
"args": str(args),
"approved": approved,
"approver": os.getenv("USER", "unknown")
}
with open(self.audit_log, 'a') as f:
f.write(json.dumps(audit_entry) + '\n')
if not approved:
raise PermissionError(f"Denied: {action_description}")
result = func(*args, **kwargs)
# Log the result
result_entry = {
**audit_entry,
"completed": True,
"result": str(result)[:200] # Truncate long results
}
with open(self.audit_log, 'a') as f:
f.write(json.dumps(result_entry) + '\n')
return result
return wrapper
import * as fs from 'fs';
class AuditedApproval {
constructor(private auditLogPath: string) {}
withAudit<T extends (...args: any[]) => any>(
func: T,
actionDescription: string
): T {
return (async (...args: any[]) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const response = await new Promise<string>(resolve => {
rl.question(`Approve '${actionDescription}'? (yes/no): `, resolve);
});
rl.close();
const approved = response.toLowerCase() === 'yes';
// Log the decision
const auditEntry = {
timestamp: new Date().toISOString(),
action: actionDescription,
args: JSON.stringify(args),
approved,
approver: process.env.USER || 'unknown'
};
fs.appendFileSync(this.auditLogPath, JSON.stringify(auditEntry) + '\n');
if (!approved) {
throw new Error(`Denied: ${actionDescription}`);
}
const result = await func(...args);
// Log the result
const resultEntry = {
...auditEntry,
completed: true,
result: String(result).slice(0, 200) // Truncate long results
};
fs.appendFileSync(this.auditLogPath, JSON.stringify(resultEntry) + '\n');
return result;
}) as T;
}
}
Why This Approach Works
Key insight: Agentica SDK agents call real functions, not schema definitions. This means HITL is just wrapping functions — no special framework features needed.
delete_file to an agent, the agent literally calls your delete_file function through RPC via Warp. There’s no schema layer, no tool registry, no execution middleware.
This means:
- You control execution — wrap functions however you want
- Use any approval mechanism — terminal, web, Slack, custom systems
- Standard programming patterns — decorators, higher-order functions, classes
- No framework lock-in — approval logic is in your code, not the framework
Best Practices
Make Denial Clear to the Agent
Make Denial Clear to the Agent
When denying an action, raise an exception with a clear message. The agent sees this and can adapt:The agent might then try an alternative approach based on the error message.
# Good - agent understands why
raise PermissionError("Deployment denied: must deploy to staging first")
# Bad - agent gets generic error
raise Exception("No")
// Good - agent understands why
throw new Error("Deployment denied: must deploy to staging first");
// Bad - agent gets generic error
throw new Error("No");
Different Approval Levels for Different Risks
Different Approval Levels for Different Risks
Not everything needs the same oversight:
- Low risk: Auto-approve, just log
- Medium risk: CLI approval for development
- High risk: Dashboard with multiple approvers
- Critical: Require manager approval + audit trail
Make Approval Optional per Environment
Make Approval Optional per Environment
Use environment variables to control approval requirements:
def deploy_with_approval(config: DeploymentConfig) -> str:
# Production always requires approval
# Other environments only if REQUIRE_APPROVAL=true
needs_approval = (
config.environment == "production" or
os.getenv("REQUIRE_APPROVAL") == "true"
)
if needs_approval:
# ... approval logic
pass
return deploy(config)
function deployWithApproval(config: DeploymentConfig): string {
const needsApproval = (
config.environment === 'production' ||
process.env.REQUIRE_APPROVAL === 'true'
);
if (needsApproval) {
// ... approval logic
}
return deploy(config);
}
Timeout Approval Requests
Timeout Approval Requests
Don’t wait forever for approval:
import asyncio
async def with_timeout_approval(func, action_description: str, timeout_seconds: int = 300):
async def wrapper(*args, **kwargs):
# Start approval request in background
approval_task = asyncio.create_task(
get_approval_async(action_description, args)
)
try:
# Wait for approval with timeout
approved = await asyncio.wait_for(approval_task, timeout=timeout_seconds)
if not approved:
raise PermissionError(f"Denied: {action_description}")
except asyncio.TimeoutError:
raise TimeoutError(f"Approval timeout for: {action_description}")
return func(*args, **kwargs)
return wrapper
function withTimeoutApproval<T extends (...args: any[]) => any>(
func: T,
actionDescription: string,
timeoutSeconds: number = 300
): T {
return (async (...args: any[]) => {
const approvalPromise = getApprovalAsync(actionDescription, args);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Approval timeout')), timeoutSeconds * 1000)
);
try {
const approved = await Promise.race([approvalPromise, timeoutPromise]);
if (!approved) {
throw new Error(`Denied: ${actionDescription}`);
}
} catch (e) {
throw new Error(`Approval timeout for: ${actionDescription}`);
}
return func(...args);
}) as T;
}
Next Steps
Best Practices
Production-ready patterns and security
Multi-Agent Systems
Apply HITL in multi-agent workflows
Error Handling
Handle approval denials and failures
Examples
Explore examples & get inspired