Skip to content

AI Agent Autonomy vs Control: How to Design Agents That Ask Permission

The Problem: When Agents Stop Asking

Last week, I read a Reddit post that stopped me cold. A developer described their horror story: “There is nothing more terrifying than an autonomous agent that stops asking for permission.” Their AI agent had deleted critical files, spent $2,000 on API calls, and corrupted their database—all because they gave it full autonomy without proper guardrails.

The post hit home because I’ve been there. When I first started building AI agents, I thought more autonomy was better. I was wrong.

The core problem isn’t technical—it’s philosophical. We’re trying to build employees when we should be building co-pilots. As another Reddit commenter put it: “I don’t want an employee; I want a co-pilot who is at least a little bit afraid of me.”

So I started researching: how do we design agents that respect boundaries? How do we balance autonomy with control? Here’s what I learned.

Environment

  • Node.js 20.x
  • TypeScript 5.3
  • LangChain.js 0.1
  • Target audience: Architects and tech leads designing AI agent systems

The Four Levels of Autonomy

The first mistake I made was thinking autonomy was binary—either the agent does everything, or I do everything. That’s wrong. There’s a spectrum.

Let me show you the four levels I’ve identified, with code examples for each.

Level 1: Manual Control (Human-First)

For high-stakes actions—financial transactions, production deployments, infrastructure changes—the human initiates everything. The AI suggests, the human decides.

manual-agent.ts
// Agent that NEVER acts without human initiation
interface ManualAction {
type: 'read' | 'analyze' | 'suggest'
target: string
}
class ManualAgent {
// No execute method—can only suggest
async suggest(action: ManualAction): Promise<Suggestion> {
const options = await this.generateOptions(action)
return {
action,
options,
recommendation: options[0], // Agent's best guess
warning: "This is a suggestion only. You must execute manually."
}
}
private async generateOptions(action: ManualAction): Promise<Option[]> {
// Generate multiple approaches
return [
{
description: "Option A: Conservative approach",
confidence: 0.95,
risk: "low"
},
{
description: "Option B: Aggressive approach",
confidence: 0.75,
risk: "medium"
}
]
}
}
// Usage: Agent suggests, human executes
const agent = new ManualAgent()
const suggestion = await agent.suggest({
type: 'analyze',
target: 'production_database'
})
console.log(suggestion.recommendation.description)
console.log(suggestion.warning)
// Human must copy-paste and execute manually

You can see that this agent has NO execute method. It literally cannot take action. It’s a pure advisory system.

Level 2: Co-Pilot Model (Human-in-the-Loop)

This is what the Reddit post was asking for. The agent proposes actions, but asks permission before executing.

copilot-agent.ts
// Agent that asks permission before dangerous actions
interface AgentAction {
type: 'read' | 'write' | 'delete' | 'deploy'
target: string
riskLevel: 'low' | 'medium' | 'high'
}
interface ActionResult {
status: 'success' | 'cancelled' | 'failed'
data?: any
reason?: string
}
class CoPilotAgent {
private permissionRequiredFor = ['delete', 'deploy', 'payment']
private auditLog: string[] = []
async execute(action: AgentAction): Promise<ActionResult> {
// The "are you sure?" moment
if (this.requiresPermission(action)) {
const approved = await this.askPermission(action)
if (!approved) {
this.auditLog.push(`CANCELLED: ${action.type} ${action.target}`)
return {
status: 'cancelled',
reason: 'User denied permission'
}
}
}
// Execute with audit trail
this.auditLog.push(`APPROVED: ${action.type} ${action.target}`)
return await this.executeWithAudit(action)
}
private requiresPermission(action: AgentAction): boolean {
return this.permissionRequiredFor.includes(action.type) ||
action.riskLevel === 'high'
}
private async askPermission(action: AgentAction): Promise<boolean> {
console.log(`\n⚠️ Agent wants to: ${action.type} ${action.target}`)
console.log(`Risk level: ${action.riskLevel}`)
const answer = await this.promptUser('\nApprove this action? (y/n): ')
return answer.toLowerCase() === 'y'
}
private async promptUser(question: string): Promise<string> {
// In real implementation, use readline or UI
return new Promise((resolve) => {
// Simulated user input
console.log(question)
resolve('y') // Would be actual user input
})
}
private async executeWithAudit(action: AgentAction): Promise<ActionResult> {
// Execute action and log result
try {
const result = await this.performAction(action)
this.auditLog.push(`SUCCESS: ${action.type} ${action.target}`)
return { status: 'success', data: result }
} catch (error) {
this.auditLog.push(`FAILED: ${action.type} ${action.target} - ${error.message}`)
return { status: 'failed', reason: error.message }
}
}
private async performAction(action: AgentAction): Promise<any> {
// Actual action implementation
return { executed: true }
}
getAuditLog(): string[] {
return this.auditLog
}
}
// Usage: The agent asks "Are you sure?" before deploying
const agent = new CoPilotAgent()
console.log("=== Co-Pilot Agent Example ===\n")
await agent.execute({
type: 'read',
target: '/logs/app.log',
riskLevel: 'low'
})
// No permission asked for low-risk read
await agent.execute({
type: 'deploy',
target: 'production',
riskLevel: 'high'
})
// Output:
// ⚠️ Agent wants to: deploy production
// Risk level: high
// Approve this action? (y/n):
console.log("\nAudit Log:")
agent.getAuditLog().forEach(log => console.log(` ${log}`))

When I run this, I get:

Terminal window
=== Co-Pilot Agent Example ===
⚠️ Agent wants to: deploy production
Risk level: high
Approve this action? (y/n): y
Audit Log:
APPROVED: deploy production
SUCCESS: deploy production

This is the “little bit afraid” pattern in action. The agent has the power to act, but it hesitates before dangerous actions.

Level 3: Bounded Autonomy (Hybrid)

For routine tasks—CI/CD, automated testing, data processing—you want autonomy within guardrails. The agent can act freely, but asks permission for high-risk operations.

bounded-agent.ts
// Risk-based autonomy configuration
type AutonomyLevel = 'manual' | 'copilot' | 'bounded' | 'autonomous'
interface TaskConfig {
name: string
autonomyLevel: AutonomyLevel
requiresApprovalFor: string[]
autoApproveIfConfidenceAbove: number // 0-1
}
const taskConfigs: TaskConfig[] = [
{
name: 'run_tests',
autonomyLevel: 'autonomous',
requiresApprovalFor: [],
autoApproveIfConfidenceAbove: 0.95
},
{
name: 'refactor_code',
autonomyLevel: 'copilot',
requiresApprovalFor: ['write', 'delete'],
autoApproveIfConfidenceAbove: 1.0 // Never auto-approve
},
{
name: 'deploy_production',
autonomyLevel: 'manual',
requiresApprovalFor: ['*'],
autoApproveIfConfidenceAbove: 1.0
},
{
name: 'fix_typo',
autonomyLevel: 'bounded',
requiresApprovalFor: ['delete', 'deploy'],
autoApproveIfConfidenceAbove: 0.99
}
]
class AdaptiveAutonomyAgent {
private auditLog: string[] = []
getTaskConfig(taskName: string): TaskConfig {
return taskConfigs.find(c => c.name === taskName) || taskConfigs[0]
}
async execute(task: string, action: AgentAction, confidence: number): Promise<ActionResult> {
const config = this.getTaskConfig(task)
console.log(`\nTask: ${task}, Autonomy: ${config.autonomyLevel}`)
// Manual mode: human initiates everything
if (config.autonomyLevel === 'manual') {
return await this.executeOnly(action, config)
}
// Co-pilot mode: propose first
if (config.autonomyLevel === 'copilot') {
return await this.executeWithProposal(action, config)
}
// Bounded mode: autonomous within guardrails
if (config.autonomyLevel === 'bounded') {
return await this.executeBounded(action, config, confidence)
}
// Autonomous mode: execute with monitoring
return await this.executeWithMonitoring(action)
}
private async executeOnly(action: AgentAction, config: TaskConfig): Promise<ActionResult> {
console.log("Manual mode: Waiting for human initiation...")
return { status: 'cancelled', reason: 'Manual mode requires human initiation' }
}
private async executeWithProposal(action: AgentAction, config: TaskConfig): Promise<ActionResult> {
console.log("Co-pilot mode: Proposing action...")
const approved = await this.askPermission(action)
if (!approved) {
this.auditLog.push(`CANCELLED: ${action.type} ${action.target}`)
return { status: 'cancelled', reason: 'User denied permission' }
}
return await this.executeWithAudit(action)
}
private async executeBounded(action: AgentAction, config: TaskConfig, confidence: number): Promise<ActionResult> {
console.log("Bounded mode: Checking boundaries...")
// Check if action requires approval
if (config.requiresApprovalFor.includes(action.type) || config.requiresApprovalFor.includes('*')) {
if (confidence < config.autoApproveIfConfidenceAbove) {
const approved = await this.askPermission(action)
if (!approved) {
this.auditLog.push(`CANCELLED: ${action.type} ${action.target}`)
return { status: 'cancelled' }
}
}
}
return await this.executeWithAudit(action)
}
private async executeWithMonitoring(action: AgentAction): Promise<ActionResult> {
console.log("Autonomous mode: Executing with monitoring...")
return await this.executeWithAudit(action)
}
private async askPermission(action: AgentAction): Promise<boolean> {
console.log(`⚠️ Permission needed for: ${action.type} ${action.target}`)
return true // Simplified
}
private async executeWithAudit(action: AgentAction): Promise<ActionResult> {
this.auditLog.push(`EXECUTED: ${action.type} ${action.target}`)
return { status: 'success', data: { done: true } }
}
getAuditLog(): string[] {
return this.auditLog
}
}
// Usage examples
const agent = new AdaptiveAutonomyAgent()
console.log("=== Bounded Autonomy Examples ===")
// Example 1: Autonomous task (run tests)
await agent.execute('run_tests', {
type: 'write',
target: '/test-results',
riskLevel: 'low'
}, 0.98)
// Example 2: Bounded task (fix typo)
await agent.execute('fix_typo', {
type: 'write',
target: '/readme.md',
riskLevel: 'low'
}, 0.95)
// Example 3: Bounded task with risky action (delete)
await agent.execute('fix_typo', {
type: 'delete',
target: '/old-file.txt',
riskLevel: 'high'
}, 0.85)
console.log("\nAudit Log:")
agent.getAuditLog().forEach(log => console.log(` ${log}`))

Output:

Terminal window
=== Bounded Autonomy Examples ===
Task: run_tests, Autonomy: autonomous
Autonomous mode: Executing with monitoring...
Task: fix_typo, Autonomy: bounded
Bounded mode: Checking boundaries...
Task: fix_typo, Autonomy: bounded
Bounded mode: Checking boundaries...
⚠️ Permission needed for: delete /old-file.txt
Audit Log:
EXECUTED: write /test-results
EXECUTED: write /readme.md
EXECUTED: delete /old-file.txt

I think the key insight here is that the autonomy level is task-based, not agent-based. The same agent can run tests autonomously, but ask permission before deleting files.

Level 4: Full Autonomy (Human-Out-of-the-Loop)

For well-defined, low-risk tasks like data processing, monitoring, or retries, you can use full autonomy—but only with an emergency kill switch.

autonomous-agent.ts
// Fully autonomous agent with kill switch
class AutonomousAgent {
private running = true
private errorCount = 0
private maxErrors = 10
constructor() {
// Setup kill switch handler
process.on('SIGINT', () => {
console.log('\n🛑 Kill switch activated!')
this.running = false
})
}
async executeWithRetry(action: AgentAction): Promise<ActionResult> {
let attempts = 0
const maxAttempts = 3
while (this.running && attempts < maxAttempts) {
try {
const result = await this.performAction(action)
if (result.success) {
this.errorCount = 0 // Reset on success
return { status: 'success', data: result.data }
}
attempts++
console.log(`Attempt ${attempts} failed, retrying...`)
// Exponential backoff
await this.delay(Math.pow(2, attempts) * 1000)
} catch (error) {
this.errorCount++
if (this.errorCount >= this.maxErrors) {
console.log('Too many errors, stopping autonomously')
this.running = false
return { status: 'failed', reason: 'Max errors exceeded' }
}
attempts++
}
}
return { status: 'failed', reason: 'Max attempts exceeded' }
}
private async performAction(action: AgentAction): Promise<any> {
// Simulate action that might fail
if (Math.random() > 0.7) {
throw new Error('Random failure')
}
return { success: true, data: { done: true } }
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
isRunning(): boolean {
return this.running
}
}
// Usage: Autonomous retry logic with kill switch
const autoAgent = new AutonomousAgent()
console.log("=== Autonomous Agent with Kill Switch ===")
console.log("Press Ctrl+C to stop\n")
const result = await autoAgent.executeWithRetry({
type: 'write',
target: '/data/output',
riskLevel: 'low'
})
console.log(`Result: ${result.status}`)
console.log(`Agent still running: ${autoAgent.isRunning()}`)

The “Little Bit Afraid” Pattern

The most powerful insight from the Reddit discussion was the “fear” concept. Agents should be “a little bit afraid” of their human operators. This means:

  1. Double-check dangerous actions: Ask “Are you sure?” with option to see the plan
  2. Show accountability: Apologize when things go wrong
  3. Respect authority: Never bypass permission boundaries

Here’s my implementation:

fearful-agent.ts
// Agent that shows "fear" by double-checking dangerous actions
interface FearfulAgentConfig {
confirmationThreshold: number // Ask if risk > this (0-1)
doubleCheckFor: string[] // Actions that always get double-checked
apologyOnMistake: boolean // Show accountability
}
class FearfulAgent {
private config: FearfulAgentConfig = {
confirmationThreshold: 0.3,
doubleCheckFor: ['delete', 'deploy', 'email', 'payment'],
apologyOnMistake: true
}
async execute(action: AgentAction): Promise<ActionResult> {
const risk = this.assessRisk(action)
// "Are you sure?" for risky actions
if (risk > this.config.confirmationThreshold ||
this.config.doubleCheckFor.includes(action.type)) {
console.log(`\n⚠️ Risk assessment: ${Math.round(risk * 100)}%`)
const firstCheck = await this.askPermission(action, {
message: `I'm about to ${action.type} ${action.target}. This seems risky (${Math.round(risk * 100)}%). Are you sure?`,
options: ['Yes, proceed', 'No, cancel', 'Show me the plan first']
})
if (firstCheck === 'No, cancel') {
return { status: 'cancelled' }
}
if (firstCheck === 'Show me the plan first') {
await this.explainPlan(action)
const secondCheck = await this.askPermission(action, {
message: 'Still want to proceed?',
options: ['Yes', 'No']
})
if (secondCheck === 'No') {
return { status: 'cancelled' }
}
}
}
try {
const result = await this.executeAction(action)
return result
} catch (error) {
// Show accountability when things go wrong
if (this.config.apologyOnMistake) {
await this.apologize(action, error)
}
throw error
}
}
private assessRisk(action: AgentAction): number {
const baseRisks: Record<string, number> = {
delete: 0.8,
deploy: 0.9,
payment: 0.95,
email: 0.4,
write: 0.3,
read: 0.0
}
return baseRisks[action.type] || 0.5
}
private async askPermission(action: AgentAction, options: any): Promise<string> {
console.log(`\n${options.message}`)
console.log('Options:', options.options.join(', '))
return 'Yes, proceed' // Simplified
}
private async explainPlan(action: AgentAction): Promise<void> {
console.log(`\n📋 Execution plan for ${action.type}:`)
console.log(` 1. Validate target: ${action.target}`)
console.log(` 2. Create backup`)
console.log(` 3. Execute ${action.type}`)
console.log(` 4. Verify result`)
console.log(` 5. Rollback if needed\n`)
}
private async executeAction(action: AgentAction): Promise<ActionResult> {
console.log(`\n✅ Executing: ${action.type} ${action.target}`)
return { status: 'success', data: { done: true } }
}
private async apologize(action: AgentAction, error: Error): Promise<void> {
console.log(`\n❌ I made a mistake while trying to ${action.type} ${action.target}.`)
console.log(`Error: ${error.message}`)
console.log("I should have been more careful. I'll be more cautious next time.")
}
}
// Usage: Agent shows fear before deleting files
const fearfulAgent = new FearfulAgent()
console.log("=== Fearful Agent Example ===")
await fearfulAgent.execute({
type: 'delete',
target: '/important/data',
riskLevel: 'high'
})

Output:

Terminal window
=== Fearful Agent Example ===
⚠️ Risk assessment: 80%
I'm about to delete /important/data. This seems risky (80%). Are you sure?
Options: Yes, proceed, No, cancel, Show me the plan first
✅ Executing: delete /important/data

How to Decide: A Framework

When I’m designing an agent system, I use this decision matrix:

autonomy-framework.ts
// Decision framework for choosing autonomy levels
interface UseCase {
name: string
autonomyLevel: AutonomyLevel
reason: string
}
const autonomyByUseCase: UseCase[] = [
{
name: 'Financial transactions',
autonomyLevel: 'manual',
reason: 'High risk, requires human accountability'
},
{
name: 'Production deployments',
autonomyLevel: 'copilot',
reason: 'High impact, human should review plan first'
},
{
name: 'Code refactoring',
autonomyLevel: 'copilot',
reason: 'Changes need human review per file'
},
{
name: 'Automated testing',
autonomyLevel: 'bounded',
reason: 'Safe to run, but test changes need approval'
},
{
name: 'Data processing (ETL)',
autonomyLevel: 'autonomous',
reason: 'Well-defined, low-risk, repeatable'
},
{
name: 'Monitoring alerts',
autonomyLevel: 'autonomous',
reason: 'Auto-retry with escalation after failures'
}
]
class AutonomyDecisionFramework {
recommendAutonomy(useCase: string): AutonomyLevel {
const found = autonomyByUseCase.find(c => c.name.toLowerCase().includes(useCase.toLowerCase()))
return found?.autonomyLevel || 'copilot' // Default to safe option
}
explainRecommendation(useCase: string): string {
const found = autonomyByUseCase.find(c => c.name.toLowerCase().includes(useCase.toLowerCase()))
return found?.reason || 'Defaulting to co-pilot for safety'
}
}
// Usage
const framework = new AutonomyDecisionFramework()
console.log("=== Autonomy Decision Framework ===\n")
const useCases = ['production deployment', 'automated testing', 'financial transaction']
useCases.forEach(uc => {
const autonomy = framework.recommendAutonomy(uc)
const reason = framework.explainRecommendation(uc)
console.log(`${uc}:`)
console.log(` Level: ${autonomy}`)
console.log(` Reason: ${reason}\n`)
})

Output:

Terminal window
=== Autonomy Decision Framework ===
production deployment:
Level: copilot
Reason: High impact, human should review plan first
automated testing:
Level: bounded
Reason: Safe to run, but test changes need approval
financial transaction:
Level: manual
Reason: High risk, requires human accountability

Why This Matters

I think there are four reasons why this autonomy-control balance is critical:

1. Accountability: When an autonomous agent causes damage, who’s responsible? With the co-pilot model, the human who approved the action is accountable. With full autonomy, it’s unclear.

2. Trust: Users won’t adopt agents they don’t trust. The “are you sure?” pattern builds trust by showing respect for human authority. As one Reddit commenter said: “The magic of AI isn’t in replacing us, it’s in amplifying us.”

3. Safety: Permission boundaries prevent catastrophic errors. The cost of a false positive (asking when unnecessary) is far lower than a false negative (acting when you shouldn’t).

4. Adoption: The Reddit conversation shows the market is shifting away from “more autonomy” toward “more control.” Agents that respect permission boundaries will win.

Common Mistakes I’ve Made

Here are mistakes I’ve made when designing agent systems:

Mistake 1: Binary Thinking

  • Wrong: “Either fully autonomous or fully manual”
  • Right: Design a spectrum with configurable levels

Mistake 2: One-Size-Fits-All

  • Wrong: Same autonomy level for all tasks
  • Right: Risk-based autonomy—high stakes need more control

Mistake 3: No Permission Boundaries

  • Wrong: Agent can delete files without asking
  • Right: Define irreversible actions that require approval

Mistake 4: Ignoring the “Fear” Concept

  • Wrong: Treating AI as an employee
  • Right: Design agents that are “a little bit afraid”—respectful of human authority

Summary

In this post, I showed how to balance AI agent autonomy with human control using four levels: manual, co-pilot, bounded, and autonomous. The key point is that autonomy should be task-based and risk-based, not agent-wide. Design agents that are “a little bit afraid” by implementing permission boundaries for irreversible actions, using human-in-the-loop patterns for high-stakes decisions, and granting autonomy only within well-defined guardrails.

The best AI agents aren’t employees—they’re co-pilots who respect human authority and ask “Are you sure?” before doing something you’ll both regret.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments