Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Commit 340fa2b

Browse files
committed
feat(docs): add human in the loop
1 parent c004f66 commit 340fa2b

5 files changed

Lines changed: 382 additions & 2 deletions

File tree

site/src/config/navigation.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,16 @@ sidebar:
8282
- docs/user-guide/concepts/tools/vended-tools
8383
- label: Plugins
8484
items:
85-
- docs/user-guide/concepts/plugins
85+
- label: "Overview"
86+
slug: docs/user-guide/concepts/plugins
8687
- docs/user-guide/concepts/plugins/skills
8788
- docs/user-guide/concepts/plugins/steering
8889
- docs/user-guide/concepts/plugins/context-offloader
89-
- docs/user-guide/concepts/agents/interventions
90+
- label: Interventions
91+
items:
92+
- label: "Overview"
93+
slug: docs/user-guide/concepts/agents/interventions
94+
- docs/user-guide/concepts/agents/human-in-the-loop
9095
- label: Streaming
9196
items:
9297
- docs/user-guide/concepts/streaming/async-iterators
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
---
2+
title: Human in the Loop
3+
tags: [hooks, tool-execution]
4+
description: "Gate agent tool execution behind human approval. Configurable modes for CLI, web, and custom UIs with optional session trust."
5+
sidebar:
6+
badge:
7+
text: New
8+
variant: tip
9+
---
10+
11+
The `HumanInTheLoop` intervention handler pauses agent execution before tool calls to request human approval. It provides a configurable, drop-in way to add human oversight without writing custom interrupt logic — just pass it to `interventions` and choose how you want to collect the human's response.
12+
13+
:::note[TypeScript only]
14+
`HumanInTheLoop` is currently available in the TypeScript SDK only. For Python, see [Interrupts](../interrupts) for building equivalent approval workflows with the hook system.
15+
:::
16+
17+
## How It Works
18+
19+
```mermaid
20+
flowchart LR
21+
A[Tool call] --> B{Allowed?}
22+
B -->|Yes| C[Execute]
23+
B -->|No| D{Trusted?}
24+
D -->|Yes| C
25+
D -->|No| E{Human approves?}
26+
E -->|Yes| C
27+
E -->|No| F[Cancel]
28+
```
29+
30+
The handler uses the [`confirm` action](./interventions) to pause for human input. Under the hood it builds on the SDK's [interrupt mechanism](../interrupts), but abstracts away the manual interrupt/resume loop when you provide an `ask` option.
31+
32+
## Getting Started
33+
34+
### Interrupt/Resume Mode (Default)
35+
36+
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 — the same [interrupt/resume pattern](../interrupts#hooks) used throughout the SDK. For stateless deployments, combine with a [session manager](./session-management) to persist state between requests.
37+
38+
```typescript
39+
--8<-- "user-guide/concepts/agents/human-in-the-loop_imports.ts:basic_interrupt_imports"
40+
41+
--8<-- "user-guide/concepts/agents/human-in-the-loop.ts:basic_interrupt"
42+
```
43+
44+
### Stdio Mode
45+
46+
For CLI applications, pass `ask: 'stdio'` to prompt the user inline via Node.js readline. The agent blocks until the user responds — no interrupt handling needed on the caller side.
47+
48+
```typescript
49+
--8<-- "user-guide/concepts/agents/human-in-the-loop.ts:stdio_mode"
50+
```
51+
52+
### Custom UI Callback
53+
54+
For web UIs, Slack bots, or other custom interfaces, pass an async function to `ask`. The function receives a prompt string describing the tool call and must return the user's response.
55+
56+
```typescript
57+
--8<-- "user-guide/concepts/agents/human-in-the-loop.ts:custom_ask"
58+
```
59+
60+
## Configuration
61+
62+
| Parameter | Type | Default | Description |
63+
|-----------|------|---------|-------------|
64+
| `allowedTools` | `string[]` | `undefined` | Tools that bypass approval. Supports `'*'` (all) and `'!toolName'` (negation). |
65+
| `enableTrust` | `boolean` | `false` | When `true`, trust responses are remembered for the session. |
66+
| `evaluateTrust` | Function | Accepts `'t'` or `'trust'` | Custom validator for trust responses. Only evaluated when `enableTrust` is `true`. |
67+
| `evaluate` | Function | Accepts `true`, `'y'`, or `'yes'` | Custom validator for approval responses. |
68+
| `ask` | Function or `'stdio'` | `undefined` | Pass an async function for custom UIs, `'stdio'` for CLI readline, or omit for interrupt/resume. |
69+
70+
### Allowed Tools
71+
72+
Use `allowedTools` to skip approval for safe, read-only tools:
73+
74+
```typescript
75+
--8<-- "user-guide/concepts/agents/human-in-the-loop.ts:allowed_tools"
76+
```
77+
78+
### Trust Mode
79+
80+
When `enableTrust` is `true`, 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.
81+
82+
Trust state is stored in `agent.appState` and persists across turns within a session but resets when the agent is re-created. Negated tools (`'!toolName'`) cannot be trusted — they always prompt.
83+
84+
```typescript
85+
--8<-- "user-guide/concepts/agents/human-in-the-loop.ts:trust_mode"
86+
```
87+
88+
### Custom Evaluate
89+
90+
By default, the handler accepts `true`, `'y'`, or `'yes'` as approval. Use `evaluate` to define your own approval logic — for example, requiring a one-time passcode:
91+
92+
```typescript
93+
--8<-- "user-guide/concepts/agents/human-in-the-loop.ts:custom_evaluate"
94+
```
95+
96+
## Comparison with Raw Interrupts
97+
98+
- **Setup**`interventions: [new HumanInTheLoop()]` vs. writing a custom hook class + resume loop
99+
- **Inline collection** — Built-in (`ask: 'stdio'` or callback) vs. implementing yourself
100+
- **Trust/remember** — Built-in (`enableTrust`) vs. manual `appState` logic
101+
- **Tool allow-list** — Built-in (`allowedTools`) vs. custom conditional logic
102+
- **Flexibility** — HITL is opinionated for approval workflows; [raw interrupts](../interrupts) give full control over any interrupt shape
103+
104+
Use `HumanInTheLoop` when you want standard approval gating with minimal code. Use [raw interrupts](../interrupts) when you need custom interrupt shapes, multi-step interactions, or non-approval workflows.
105+
106+
## Related Topics
107+
108+
- [Interventions](./interventions) — The intervention handler framework that HITL is built on
109+
- [Interrupts](../interrupts) — Low-level interrupt/resume mechanism
110+
- [Agent State](./state) — How trust decisions persist via `appState`
111+
- [Session Management](./session-management) — Persisting interrupt state across sessions
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { Agent, tool, InterruptResponseContent, SessionManager, FileStorage } from '@strands-agents/sdk'
2+
import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'
3+
import { z } from 'zod'
4+
5+
// =====================
6+
// Basic (Interrupt/Resume Mode)
7+
// =====================
8+
9+
async function basicInterruptExample() {
10+
// --8<-- [start:basic_interrupt]
11+
const deleteFiles = tool({
12+
name: 'delete_files',
13+
description: 'Delete files at the given paths',
14+
inputSchema: z.object({ paths: z.array(z.string()) }),
15+
callback: (input) => `Deleted ${input.paths.length} files`,
16+
})
17+
18+
const agent = new Agent({
19+
tools: [deleteFiles],
20+
interventions: [new HumanInTheLoop()],
21+
})
22+
23+
// Agent pauses with stopReason 'interrupt' when a tool requires approval
24+
let result = await agent.invoke('Delete the temp files')
25+
26+
if (result.stopReason === 'interrupt') {
27+
// Present the interrupt to your user (web UI, Slack, push notification, etc.)
28+
console.log(result.interrupts![0].reason)
29+
30+
// Resume with the human's response
31+
result = await agent.invoke([
32+
new InterruptResponseContent({
33+
interruptId: result.interrupts![0].id,
34+
response: 'yes', // 'y', 'yes', or true → approved. Anything else → denied.
35+
}),
36+
])
37+
}
38+
39+
console.log('Result:', result.lastMessage)
40+
// --8<-- [end:basic_interrupt]
41+
}
42+
43+
// =====================
44+
// Stdio Mode
45+
// =====================
46+
47+
async function stdioModeExample() {
48+
// --8<-- [start:stdio_mode]
49+
const agent = new Agent({
50+
tools: [deleteFiles],
51+
interventions: [new HumanInTheLoop({ ask: 'stdio' })],
52+
})
53+
54+
await agent.invoke('Delete the temp files')
55+
// Terminal: Tool "delete_files" requires human approval. Input: {"paths":[...]} (y/n):
56+
// --8<-- [end:stdio_mode]
57+
}
58+
59+
// =====================
60+
// Custom Ask Callback
61+
// =====================
62+
63+
async function customAskExample() {
64+
// --8<-- [start:custom_ask]
65+
const agent = new Agent({
66+
tools: [deleteFiles],
67+
interventions: [
68+
new HumanInTheLoop({
69+
ask: async (prompt) => {
70+
// Your UI: Slack DM, web modal, push notification, etc.
71+
return await askUserViaSlack(prompt)
72+
},
73+
}),
74+
],
75+
})
76+
77+
await agent.invoke('Delete the temp files')
78+
// --8<-- [end:custom_ask]
79+
}
80+
81+
// =====================
82+
// Allowed Tools
83+
// =====================
84+
85+
async function allowedToolsExample() {
86+
// --8<-- [start:allowed_tools]
87+
const agent = new Agent({
88+
tools: [readFile, deleteFiles],
89+
interventions: [
90+
new HumanInTheLoop({
91+
ask: 'stdio',
92+
// Pattern syntax:
93+
// 'read_file' → this tool runs without approval
94+
// '*' → all tools run without approval (disables handler)
95+
// ['*', '!delete_files'] → all tools run freely except delete_files
96+
allowedTools: ['read_file'],
97+
}),
98+
],
99+
})
100+
101+
await agent.invoke('Read config.json then delete /tmp/old-logs')
102+
// Only delete_files prompts for approval; read_file executes immediately
103+
// --8<-- [end:allowed_tools]
104+
}
105+
106+
// =====================
107+
// Trust Mode
108+
// =====================
109+
110+
async function trustModeExample() {
111+
// --8<-- [start:trust_mode]
112+
const agent = new Agent({
113+
tools: [deleteFiles],
114+
interventions: [
115+
new HumanInTheLoop({
116+
ask: 'stdio',
117+
enableTrust: true,
118+
}),
119+
],
120+
})
121+
122+
await agent.invoke('Delete all log files in /tmp')
123+
// First delete_files call: user responds 't' → approved AND remembered
124+
// Subsequent delete_files calls: no prompt needed for the rest of the session
125+
// --8<-- [end:trust_mode]
126+
}
127+
128+
// =====================
129+
// Cloud / API Server (Interrupt/Resume with Session)
130+
// =====================
131+
132+
async function cloudApiExample() {
133+
// --8<-- [start:cloud_api]
134+
const deleteFiles = tool({
135+
name: 'delete_files',
136+
description: 'Delete files at the given paths',
137+
inputSchema: z.object({ paths: z.array(z.string()) }),
138+
callback: (input) => `Deleted ${input.paths.length} files`,
139+
})
140+
141+
const readFile = tool({
142+
name: 'read_file',
143+
description: 'Read a file',
144+
inputSchema: z.object({ path: z.string() }),
145+
callback: (input) => `Contents of ${input.path}`,
146+
})
147+
148+
function createAgent() {
149+
return new Agent({
150+
tools: [deleteFiles, readFile],
151+
interventions: [new HumanInTheLoop({ allowedTools: ['read_file'] })],
152+
sessionManager: new SessionManager({
153+
sessionId: 'my-session',
154+
storage: { snapshot: new FileStorage('/path/to/storage') },
155+
}),
156+
})
157+
}
158+
159+
// Request 1: Agent tries to delete → pauses with interrupt
160+
const agent = createAgent()
161+
const result = await agent.invoke('Delete the temp files')
162+
163+
if (result.stopReason === 'interrupt') {
164+
// Send interrupt details to client for approval
165+
const pending = {
166+
interruptId: result.interrupts![0].id,
167+
reason: result.interrupts![0].reason,
168+
}
169+
// ... return pending to client via HTTP response
170+
}
171+
172+
// Request 2: Client sends back approval
173+
const resumedAgent = createAgent()
174+
const finalResult = await resumedAgent.invoke([
175+
new InterruptResponseContent({
176+
interruptId: 'the-interrupt-id',
177+
response: 'yes',
178+
}),
179+
])
180+
// --8<-- [end:cloud_api]
181+
}
182+
183+
// =====================
184+
// Custom Evaluate (OTP example)
185+
// =====================
186+
187+
async function customEvaluateExample() {
188+
// --8<-- [start:custom_evaluate]
189+
const expectedOtp = '483291'
190+
191+
const agent = new Agent({
192+
tools: [transferFunds],
193+
interventions: [
194+
new HumanInTheLoop({
195+
ask: async (prompt) => {
196+
await sendOtpToUser(expectedOtp)
197+
return await collectUserInput(prompt + ' Enter OTP to confirm:')
198+
},
199+
// Only approve if the user enters the correct OTP
200+
evaluate: (response) => response === expectedOtp,
201+
}),
202+
],
203+
})
204+
205+
await agent.invoke('Transfer $500 from checking to savings')
206+
// --8<-- [end:custom_evaluate]
207+
}
208+
209+
// Suppress unused function warnings
210+
void basicInterruptExample
211+
void stdioModeExample
212+
void customAskExample
213+
void allowedToolsExample
214+
void trustModeExample
215+
void cloudApiExample
216+
void customEvaluateExample
217+
218+
declare function askUserViaSlack(prompt: string): Promise<string>
219+
declare function sendOtpToUser(otp: string): Promise<void>
220+
declare function collectUserInput(prompt: string): Promise<string>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// @ts-nocheck
2+
3+
// --8<-- [start:basic_interrupt_imports]
4+
import { Agent, tool, InterruptResponseContent } from '@strands-agents/sdk'
5+
import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'
6+
import { z } from 'zod'
7+
// --8<-- [end:basic_interrupt_imports]
8+
9+
// --8<-- [start:stdio_mode_imports]
10+
import { Agent, tool } from '@strands-agents/sdk'
11+
import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'
12+
import { z } from 'zod'
13+
// --8<-- [end:stdio_mode_imports]
14+
15+
// --8<-- [start:custom_ask_imports]
16+
import { Agent, tool } from '@strands-agents/sdk'
17+
import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'
18+
import { z } from 'zod'
19+
// --8<-- [end:custom_ask_imports]
20+
21+
// --8<-- [start:allowed_tools_imports]
22+
import { Agent, tool } from '@strands-agents/sdk'
23+
import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'
24+
import { z } from 'zod'
25+
// --8<-- [end:allowed_tools_imports]
26+
27+
// --8<-- [start:trust_mode_imports]
28+
import { Agent, tool } from '@strands-agents/sdk'
29+
import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'
30+
import { z } from 'zod'
31+
// --8<-- [end:trust_mode_imports]
32+
33+
// --8<-- [start:custom_evaluate_imports]
34+
import { Agent, tool } from '@strands-agents/sdk'
35+
import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'
36+
import { z } from 'zod'
37+
// --8<-- [end:custom_evaluate_imports]
38+
39+
// --8<-- [start:cloud_api_imports]
40+
import { Agent, tool, InterruptResponseContent, SessionManager, FileStorage } from '@strands-agents/sdk'
41+
import { HumanInTheLoop } from '@strands-agents/sdk/vended-interventions/hitl'
42+
import { z } from 'zod'
43+
// --8<-- [end:cloud_api_imports]

0 commit comments

Comments
 (0)