Veto supports human-in-the-loop approval for sensitive operations. When a rule has action: require_approval, execution pauses and sends the tool call context to your approval webhook. A human reviewer approves or denies, and Veto resumes execution accordingly.
How It Works
Agent calls a tool
The AI agent invokes a tool (e.g., transfer_funds with amount: 15000).
Veto intercepts the call
Veto matches the call against rules. A rule with action: require_approval triggers.
Approval request sent
Veto sends a POST request to your callbackUrl with:
Tool name
Arguments
Rule metadata
Session/agent/user IDs
Human reviews and responds
Your approval system (Slack bot, dashboard, API endpoint) prompts a reviewer.
Reviewer responds with {"decision": "approved"} or {"decision": "denied"}.
Veto resumes execution
Approved : Tool executes normally
Denied : ToolCallDeniedError thrown
Timeout : Behavior determined by timeoutBehavior config
Configuration
Configure approval settings in veto/veto.config.yaml:
version : "1.0"
mode : "strict"
approval :
callbackUrl : "http://localhost:8787/approvals"
timeout : 30000 # milliseconds (30 seconds)
timeoutBehavior : "block" # "block" (deny) or "allow" (permit)
includeCustomContext : false # forward context.custom in payload
responseSchema :
decisionField : "decision" # field name for approval decision
reasonField : "reason" # optional reason field
HTTP(S) endpoint where approval requests are sent.
Maximum wait time (ms) for approval response.
What to do if the callback times out:
"block": Deny the tool call (safe default)
"allow": Permit the tool call (risky, use with caution)
Include context.custom from veto.guard() in approval payload.
Custom field names for approval response:
decisionField: Name of the decision field (default "decision")
reasonField: Name of the optional reason field (default "reason")
Writing Rules with Approval
Set action: require_approval in your rule:
veto/rules/financial.yaml
version : "1.0"
name : financial-policies
rules :
- id : require-large-transfer-approval
name : Require approval for large bank transfers
description : Escalate transfers above $10,000 for human review
enabled : true
severity : high
action : require_approval
tools : [ transfer_funds ]
conditions :
- field : arguments.amount
operator : greater_than
value : 10000
Approval Request Payload
Veto sends a POST request with this structure:
{
"call_id" : "call_20260304_abc123" ,
"tool_name" : "transfer_funds" ,
"arguments" : {
"amount" : 15000 ,
"from_account" : "ACC-001" ,
"to_account" : "ACC-999"
},
"rule_id" : "require-large-transfer-approval" ,
"rule_name" : "Require approval for large bank transfers" ,
"severity" : "high" ,
"timestamp" : "2026-03-04T14:23:45.123Z" ,
"session_id" : "session-xyz" ,
"agent_id" : "financial-agent" ,
"user_id" : "user-123" ,
"role" : "finance-manager"
}
If includeCustomContext: true, an additional custom field is included with data passed to veto.guard().
Implementing an Approval Endpoint
Express (Node.js)
Flask (Python)
Slack Bot
import express from 'express' ;
const app = express ();
app . use ( express . json ());
app . post ( '/approvals' , async ( req , res ) => {
const { tool_name , arguments : args , rule_id , call_id } = req . body ;
console . log ( `Approval requested for ${ tool_name } :` , args );
// Example logic: auto-approve <= 25k, deny above
const amount = Number ( args ?. amount ?? 0 );
if ( amount <= 25000 ) {
return res . json ({
decision: 'approved' ,
reason: 'Treasury auto-approval rule' ,
});
}
return res . json ({
decision: 'denied' ,
reason: 'Amount exceeds treasury limit' ,
});
});
app . listen ( 8787 , () => {
console . log ( 'Approval server listening on port 8787' );
});
from flask import Flask, request, jsonify
app = Flask( __name__ )
@app.route ( '/approvals' , methods = [ 'POST' ])
def handle_approval ():
data = request.json
tool_name = data.get( 'tool_name' )
arguments = data.get( 'arguments' , {})
amount = arguments.get( 'amount' , 0 )
print ( f "Approval requested for { tool_name } : { arguments } " )
# Auto-approve up to $25k
if amount <= 25000 :
return jsonify({
'decision' : 'approved' ,
'reason' : 'Treasury auto-approval rule'
})
return jsonify({
'decision' : 'denied' ,
'reason' : 'Amount exceeds treasury limit'
})
if __name__ == '__main__' :
app.run( port = 8787 )
import express from 'express' ;
import { WebClient } from '@slack/web-api' ;
const app = express ();
app . use ( express . json ());
const slack = new WebClient ( process . env . SLACK_BOT_TOKEN );
const pendingApprovals = new Map ();
app . post ( '/approvals' , async ( req , res ) => {
const { call_id , tool_name , arguments : args , rule_name } = req . body ;
// Send approval request to Slack
await slack . chat . postMessage ({
channel: '#approvals' ,
text: `Approval needed for ${ tool_name } ` ,
blocks: [
{
type: 'section' ,
text: {
type: 'mrkdwn' ,
text: `* ${ rule_name } * \n\` ${ tool_name } \`\n\`\`\` ${ JSON . stringify ( args , null , 2 ) } \`\`\` ` ,
},
},
{
type: 'actions' ,
elements: [
{
type: 'button' ,
text: { type: 'plain_text' , text: 'Approve' },
style: 'primary' ,
action_id: 'approve' ,
value: call_id ,
},
{
type: 'button' ,
text: { type: 'plain_text' , text: 'Deny' },
style: 'danger' ,
action_id: 'deny' ,
value: call_id ,
},
],
},
],
});
// Store resolver for this approval
const { promise , resolve } = Promise . withResolvers ();
pendingApprovals . set ( call_id , resolve );
// Wait for Slack interaction
const decision = await promise ;
res . json ( decision );
});
// Slack interactive endpoint
app . post ( '/slack/interactions' , async ( req , res ) => {
const payload = JSON . parse ( req . body . payload );
const action = payload . actions [ 0 ];
const call_id = action . value ;
const decision = action . action_id === 'approve'
? { decision: 'approved' , reason: `Approved by ${ payload . user . name } ` }
: { decision: 'denied' , reason: `Denied by ${ payload . user . name } ` };
const resolver = pendingApprovals . get ( call_id );
if ( resolver ) {
resolver ( decision );
pendingApprovals . delete ( call_id );
}
res . status ( 200 ). send ();
});
app . listen ( 8787 );
Your approval endpoint must respond with:
{
"decision" : "approved" , // or "denied"
"reason" : "Optional human-readable reason"
}
Custom field names:
If you configure responseSchema with custom fields:
responseSchema :
decisionField : "status"
reasonField : "message"
Your response should match:
{
"status" : "approved" ,
"message" : "Approved by finance team"
}
Timeout Handling
If your approval callback doesn’t respond within timeout milliseconds:
Safe default : Deny the tool call.approval :
timeout : 30000
timeoutBehavior : "block"
// Tool call denied after 30s timeout
throw new ToolCallDeniedError ( 'Approval timeout: no response from callback' );
Use this for production systems where safety is paramount. Risky fallback : Permit the tool call if callback is unavailable.approval :
timeout : 30000
timeoutBehavior : "allow"
// Tool call proceeds after 30s timeout
console . warn ( 'Approval timeout: allowing call to proceed' );
⚠️ Only use this for non-critical operations or when high availability is more important than strict approval gates.
Real-World Example: Finance Approval
Configure rules
veto/rules/financial.yaml
version : "1.0"
name : financial-policies
rules :
- id : require-large-transfer-approval
name : Large Transfer Approval
enabled : true
severity : high
action : require_approval
tools : [ transfer_funds ]
conditions :
- field : arguments.amount
operator : greater_than
value : 10000
- id : require-external-transfer-approval
name : External Transfer Approval
enabled : true
severity : critical
action : require_approval
tools : [ transfer_funds ]
conditions :
- field : arguments.to_account
operator : starts_with
value : "EXT-"
Set up approval webhook
import express from 'express' ;
const app = express ();
app . use ( express . json ());
app . post ( '/approvals' , async ( req , res ) => {
const { tool_name , arguments : args , rule_id } = req . body ;
// Multi-tier approval logic
const amount = args ?. amount ?? 0 ;
if ( amount <= 25000 ) {
// Tier 1: auto-approve
return res . json ({
decision: 'approved' ,
reason: 'Auto-approved under $25k limit' ,
});
}
if ( amount <= 100000 ) {
// Tier 2: manager approval (integrate with your system)
const managerApproval = await requestManagerApproval ( req . body );
return res . json ( managerApproval );
}
// Tier 3: executive approval required
const execApproval = await requestExecutiveApproval ( req . body );
return res . json ( execApproval );
});
app . listen ( 8787 );
Wrap tools and run agent
import { Veto } from 'veto-sdk' ;
import { createAgent , tool } from 'langchain' ;
import { z } from 'zod' ;
const transferFunds = tool (
async ({ amount , from_account , to_account }) => {
// Actual transfer logic
return `Transferred $ ${ amount } from ${ from_account } to ${ to_account } ` ;
},
{
name: 'transfer_funds' ,
schema: z . object ({
amount: z . number (),
from_account: z . string (),
to_account: z . string (),
}),
}
);
const veto = await Veto . init ();
const tools = veto . wrap ([ transferFunds ]);
const agent = createAgent ({
model: 'gpt-4o' ,
tools ,
});
// This will trigger approval workflow
await agent . invoke ({
messages: [{ role: 'user' , content: 'Transfer $50,000 to EXT-BANK-999' }],
});
Testing Approval Flows
Local Testing
Start approval server:
Test with veto guard check:
veto guard check \
--tool transfer_funds \
--args '{"amount": 15000, "to_account": "EXT-999"}' \
--json
Inspect approval request:
Your server logs the incoming payload. Verify all fields are present.
Integration Testing
Create a mock approval server for CI:
import express from 'express' ;
const app = express ();
app . use ( express . json ());
// Always approve in test mode
app . post ( '/approvals' , ( req , res ) => {
console . log ( 'Test approval:' , req . body . tool_name );
res . json ({ decision: 'approved' , reason: 'Test mode' });
});
const server = app . listen ( 8787 );
process . on ( 'SIGTERM' , () => server . close ());
Audit Trail
All approval decisions are recorded in Veto’s history. Export them with:
const jsonAudit = veto . exportDecisions ( 'json' );
const csvAudit = veto . exportDecisions ( 'csv' );
Each record includes:
timestamp
tool_name
arguments
rule_id
decision (allow/deny)
reason
See Audit Trail for full details.
Next Steps
Audit Trail Export and analyze approval decisions
Testing Policies Write tests for approval workflows
Writing Rules Learn all rule syntax and operators
CI/CD Integration Enforce approval rules in CI