Human in the loop
Pause the agent before it acts and let a person approve, edit, or reject a tool call. The HumanInTheLoop intervention handler adds this oversight without custom interrupt logic: pass it to interventions and choose how to collect the human’s response. For a worked example, see Approve before your agent acts.
How It Works
Section titled “How It Works”The handler uses the confirm action to pause for human input. It builds on the SDK’s interrupt mechanism but abstracts away the manual interrupt/resume loop when you provide an ask option.
Choose how to collect the human’s response:
| Mode | When to use |
|---|---|
| Interrupt/resume (default) | Stateless deployments that persist state between requests |
"stdio" | CLI applications where the user types at a terminal |
| Custom callback | Web UIs, Slack bots, or other async interfaces |
Interrupt/Resume Mode (Default)
Section titled “Interrupt/Resume Mode (Default)”Without an ask option, the handler raises an interrupt and the agent pauses. The caller presents the interrupt to the user, collects their response, and resumes the agent. This is the same interrupt/resume pattern used throughout the SDK. For stateless deployments, combine with a session manager to persist state between requests.
agent = Agent( tools=[delete_files], interventions=[HumanInTheLoop()],)
# Agent pauses with stop_reason 'interrupt' when a tool needs approvalresult = agent("Delete the temp files")
if result.stop_reason == "interrupt": # Present the interrupt to the user (web UI, Slack, etc.) print(result.interrupts[0].reason)
# Resume with the human's response result = agent( [ { "interruptResponse": { "interruptId": result.interrupts[0].id, "response": "yes", # 'y', 'yes', or True -> approved } } ] )import { Agent, tool, InterruptResponseContent } from '@strands-agents/sdk'import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'import { z } from 'zod'
const deleteFiles = tool({ name: 'delete_files', description: 'Delete files at the given paths', inputSchema: z.object({ paths: z.array(z.string()) }), callback: (input) => `Deleted ${input.paths.length} files`,})
const agent = new Agent({ tools: [deleteFiles], interventions: [new HumanInTheLoop()],})
// Agent pauses with stopReason 'interrupt' when a tool needs approvallet result = await agent.invoke('Delete the temp files')
if (result.stopReason === 'interrupt') { // Present the interrupt to the user (web UI, Slack, etc.) console.log(result.interrupts![0].reason)
// Resume with the human's response result = await agent.invoke([ new InterruptResponseContent({ interruptId: result.interrupts![0].id, response: 'yes', // 'y', 'yes', or true → approved }), ])}Stdio Mode
Section titled “Stdio Mode”For CLI applications, pass ask="stdio"ask: 'stdio'
agent = Agent( tools=[delete_files], interventions=[HumanInTheLoop(ask="stdio")],)
agent("Delete the temp files")# Terminal prompt:# Tool "delete_files" requires human approval. Input: {...} (y/n):import { Agent, tool } from '@strands-agents/sdk'import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'import { z } from 'zod'
// const deleteFiles = tool({ ... }): same as above
const agent = new Agent({ tools: [deleteFiles], interventions: [new HumanInTheLoop({ ask: 'stdio' })],})
await agent.invoke('Delete the temp files')// Terminal prompt:// Tool "delete_files" requires human approval. Input: {...} (y/n):Custom UI Callback
Section titled “Custom UI Callback”For web UIs, Slack bots, or other custom interfaces, pass a function to ask. The function receives a prompt string describing the tool call and must return the user’s response.
async def ask(prompt: str) -> str: # Your UI: Slack DM, web modal, push notification, etc. return await ask_user_via_slack(prompt)
agent = Agent( tools=[delete_files], interventions=[HumanInTheLoop(ask=ask)],)
agent("Delete the temp files")import { Agent, tool } from '@strands-agents/sdk'import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'import { z } from 'zod'
// const deleteFiles = tool({ ... }): same as above
const agent = new Agent({ tools: [deleteFiles], interventions: [ new HumanInTheLoop({ ask: async (prompt) => { // Your UI: Slack DM, web modal, push notification, etc. return await askUserViaSlack(prompt) }, }), ],})
await agent.invoke('Delete the temp files')Risk Classifier
Section titled “Risk Classifier”Use the classifier option to let an LLM decide per-call
whether a tool needs approval. Tools in allowed_tools bypass
the classifier entirely (fast path for known-safe tools).
agent = Agent( tools=[read_file, delete_files], interventions=[ HumanInTheLoop( allowed_tools=["read_file"], classifier=True, ), ],)
agent("Read config.json then delete /tmp/old-logs")# read_file: allowed (bypasses classifier)# delete_files: classifier evaluates -> risky -> prompts humanimport { Agent, tool } from '@strands-agents/sdk'import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'import { z } from 'zod'
// const readFile = tool({ ... })// const deleteFiles = tool({ ... })
const agent = new Agent({ tools: [readFile, deleteFiles], interventions: [ new HumanInTheLoop({ allowedTools: ['read_file'], classifier: true, }), ],})
await agent.invoke('Read config.json then delete /tmp/old-logs')// read_file: allowed (bypasses classifier)// delete_files: classifier evaluates -> risky -> prompts humanTo classify with a System One model, pass it as the classifier. A System One model is
a small decision model that picks between options instead of generating text, so it
responds faster and costs less per tool call than the LLM judge. It answers one yes/no
question per tool call, judged against the same risk criteria as the built-in
classifier. An uncertain answer requires approval and shows its reason in the prompt.
TypeScript ships TypeSafeDecisionModel, which calls TypeSafe’s hosted Jev model. In
Python, subclass DecisionModel to wrap one, such as a local
Strands Decider server:
import asyncioimport jsonimport urllib.request
from strands.decisions import DecisionMetadata, DecisionModel, DecisionResult
class StrandsDecider(DecisionModel): """Asks yes/no questions of a `strands-decider serve` server."""
def __init__(self, url="http://localhost:8000/v1/systemone"): self.url = url
def _post(self, body): request = urllib.request.Request( self.url, data=json.dumps(body).encode(), headers={"content-type": "application/json"}, ) with urllib.request.urlopen(request) as response: return json.loads(response.read())
async def _ask(self, state, questions, **kwargs): wire = { qid: {"type": "noul", "instructions": q["question"]} for qid, q in questions.items() } reply = await asyncio.to_thread( self._post, {"state": state, "questions": wire} ) return DecisionResult( # "noul" is the model's probability that the answer is yes answers={qid: a["noul"] >= 0.5 for qid, a in reply["answers"].items()}, metadata=DecisionMetadata(latency_ms=int(reply["latency_ms"])), )
agent = Agent( tools=[read_file, delete_files], interventions=[ HumanInTheLoop( allowed_tools=["read_file"], classifier=StrandsDecider(), ), ],)
agent("Read config.json then delete /tmp/old-logs")# delete_files: decision model evaluates -> risky -> prompts humanimport { Agent, tool, TypeSafeDecisionModel } from '@strands-agents/sdk'import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'import { z } from 'zod'
const agent = new Agent({ tools: [readFile, deleteFiles], interventions: [ new HumanInTheLoop({ allowedTools: ['read_file'], // Reads the API key from TYPESAFE_API_KEY classifier: new TypeSafeDecisionModel(), }), ],})
await agent.invoke('Read config.json then delete /tmp/old-logs')// delete_files: decision model evaluates -> risky -> prompts humanA small model can rate more risky calls safe than an LLM judge, so check its answers on your own tool calls before relying on it, and negate tools that must always ask.
You can customize the classifier’s system prompt or model, or provide your own classification function:
# Custom system prompt and modelagent = Agent( tools=[delete_files], interventions=[ HumanInTheLoop( classifier=LLMClassifierConfig( system_prompt="Only flag destructive operations.", model=BedrockModel( model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0", ), ), ), ],)
# Or provide your own classification functiondef my_classifier(event, **kwargs): from strands.vended_interventions.hitl.classifier import ( ClassifierResult, )
is_dangerous = event.tool_use["name"].startswith("delete") return ClassifierResult( requires_human_in_the_loop=is_dangerous, reason="destructive tool" if is_dangerous else None, )
agent = Agent( tools=[delete_files], interventions=[HumanInTheLoop(classifier=my_classifier)],)import { Agent, BedrockModel, tool } from '@strands-agents/sdk'import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'import { z } from 'zod'
// Custom system prompt and modelconst agent = new Agent({ tools: [deleteFiles], interventions: [ new HumanInTheLoop({ classifier: { systemPrompt: 'Only flag destructive operations.', model: new BedrockModel({ modelId: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', }), }, }), ],})
// Or provide your own classification functionconst agentCustom = new Agent({ tools: [deleteFiles], interventions: [ new HumanInTheLoop({ classifier: (event) => ({ requiresHumanInTheLoop: event.toolUse.name.startsWith('delete'), reason: 'destructive tool', }), }), ],})Configuration
Section titled “Configuration”| Parameter | Type | Default | Description |
|---|---|---|---|
allowed_toolsallowedTools | list[str]string[] | Noneundefined | Tools that bypass approval. Supports "*" (all) and "!tool_name" (negation). |
classifier | bool, LLMClassifierConfig, DecisionModel, or callableboolean, LlmClassifierConfig, DecisionModel, or function | Noneundefined | Risk classifier. Truetrue |
enable_trustenableTrust | boolboolean | Falsefalse | When enabled, trust responses are remembered for the session. |
evaluate_trustevaluateTrust | Function | Accepts "t" or "trust" | Custom validator for trust responses. Only evaluated when trust is enabled. |
evaluate | Function | Accepts True, "y", or "yes"true, 'y', or 'yes' | Custom validator for approval responses. |
ask | Function or "stdio"Function or 'stdio' | Noneundefined | Pass a function for custom UIs, "stdio" for CLI prompting, or omit for interrupt/resume. |
Allowed Tools
Section titled “Allowed Tools”Use the allowed tools list to skip approval for safe, read-only tools:
agent = Agent( tools=[read_file, delete_files], interventions=[ HumanInTheLoop( ask="stdio", # Pattern syntax: # "read_file" -> runs without approval # "*" -> all tools run freely (disables handler) # ["*", "!delete_files"] -> all except delete_files allowed_tools=["read_file"], ), ],)
agent("Read config.json then delete /tmp/old-logs")# Only delete_files prompts; read_file executes immediatelyimport { Agent, tool } from '@strands-agents/sdk'import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'import { z } from 'zod'
// const deleteFiles = tool({ ... }): same as above// const readFile = tool({ ... })
const agent = new Agent({ tools: [readFile, deleteFiles], interventions: [ new HumanInTheLoop({ ask: 'stdio', // Pattern syntax: // 'read_file' → runs without approval // '*' → all tools run freely (disables handler) // ['*', '!delete_files'] → all except delete_files allowedTools: ['read_file'], }), ],})
await agent.invoke('Read config.json then delete /tmp/old-logs')// Only delete_files prompts; read_file executes immediatelyTrust Mode
Section titled “Trust Mode”When trust is enabled, a human can respond with 't' or 'trust' to approve the current tool call AND remember that decision for the rest of the session. Subsequent calls to the same tool skip the prompt entirely. Trust works in all modes: interrupt/resume, stdio, and custom callbacks.
Trust state is stored in agent.stateagent.appState"!tool_name") cannot be trusted and always prompt.
agent = Agent( tools=[delete_files], interventions=[ HumanInTheLoop( ask="stdio", enable_trust=True, ), ],)
agent("Delete all log files in /tmp")# First call: user responds 't' -> approved AND remembered# Subsequent calls: no prompt needed for the sessionimport { Agent, tool } from '@strands-agents/sdk'import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'import { z } from 'zod'
// const deleteFiles = tool({ ... }): same as above
const agent = new Agent({ tools: [deleteFiles], interventions: [ new HumanInTheLoop({ ask: 'stdio', enableTrust: true, }), ],})
await agent.invoke('Delete all log files in /tmp')// First call: user responds 't' → approved AND remembered// Subsequent calls: no prompt needed for the sessionCustom Evaluate
Section titled “Custom Evaluate”By default, the handler accepts True, "y", or "yes"true, 'y', or 'yes'evaluate to define your own approval logic, for example requiring the user to type “confirm”:
agent = Agent( tools=[delete_files], interventions=[ HumanInTheLoop( ask="stdio", # Only approve if the user types "confirm" evaluate=lambda response: isinstance(response, str) and response.lower() == "confirm", ), ],)
agent("Delete the temp files")# Prompt: Tool "delete_files" requires human approval. Input: {...}# User must type "confirm" to approve (not just "y" or "yes")import { Agent, tool } from '@strands-agents/sdk'import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'import { z } from 'zod'
// const deleteFiles = tool({ ... }): same as above
const agent = new Agent({ tools: [deleteFiles], interventions: [ new HumanInTheLoop({ ask: 'stdio', // Only approve if the user types "confirm" evaluate: (response) => typeof response === 'string' && response.toLowerCase() === 'confirm', }), ],})
await agent.invoke('Delete the temp files')// Prompt: Tool "delete_files" requires human approval. Input: {...}// User must type "confirm" to approve (not just "y" or "yes")When to Use
Section titled “When to Use”Use HumanInTheLoop when you want tool-level approval gating with minimal code: it handles allow-lists, trust, and collection mode out of the box. Use raw interrupts when you need full control: custom interrupt shapes, multi-step interactions, or workflows beyond simple approve/deny.
Related Topics
Section titled “Related Topics”- Interventions: The intervention handler framework that HITL is built on
- Interrupts: Low-level interrupt/resume mechanism
- Agent State: How trust decisions persist via
agent.stateagent.appState - Session Management: Persisting interrupt state across sessions