Human-in-the-Loop Tools¶
RoomKit's human-in-the-loop primitive lets AI tool calls pause execution, request input from a human user, and resume with the answer. This enables tools like AskUserQuestion, confirmation dialogs, and any workflow where the AI needs to collect information from a person before continuing.
Why Human-in-the-Loop?¶
AI agents often need to ask clarifying questions, request approval, or collect data from users mid-conversation. Without a dedicated primitive, developers resort to workarounds:
- Wrapping tool handlers — only intercepts tools in the admin chain, not the AI's own tools
- BEFORE_TOOL_USE hooks — can allow/deny but can't provide a result or pause the loop
- External state management — manual
asyncio.Eventtracking scattered across REST endpoints
RoomKit's HumanInputHandler solves this as a first-class feature with two layers:
HumanInputHandler— core async primitive for create / wait / resolve / rejectHumanInputToolHandler— ToolHandler wrapper that composes into AIChannel's tool chain
Quick Start¶
Native AIChannel (built-in tools)¶
For AI agents that define their own human-input tools:
from roomkit import RoomKit, HumanInputToolHandler, HookTrigger, HookExecution
from roomkit.channels.ai import AIChannel
from roomkit.providers.ai.anthropic import AnthropicAIProvider
kit = RoomKit()
# Define which tools need human input
human = HumanInputToolHandler(
tool_names={"AskUserQuestion", "ConfirmAction"},
timeout=300, # 5 minutes
)
ai = AIChannel(
"agent",
provider=AnthropicAIProvider(model="claude-sonnet-4-20250514", api_key="..."),
system_prompt="You are a helpful assistant. Use AskUserQuestion to ask the user.",
tool_handler=my_other_tools, # Your regular tools
human_input_handler=human, # Human-input tools
)
kit.register_channel(ai)
# React when the AI asks for human input
@kit.hook(HookTrigger.ON_USER_INPUT_REQUIRED, execution=HookExecution.SYNC)
async def notify_user(event, ctx):
# Broadcast to frontend via WebSocket, REST, etc.
await ws_manager.broadcast(event.room_id, {
"type": "question_pending",
"pending_id": event.pending_id,
"tool_name": event.tool_name,
"arguments": event.arguments,
})
# When the user answers (from your REST endpoint, WebSocket handler, etc.)
human.handler.resolve(pending_id, '{"answer": "blue"}')
External Provider (Claude Code)¶
For providers that execute tools internally (like Claude Code sandboxes), use HumanInputHandler directly inside your ExternalToolHandler:
from roomkit.tools.human_input import HumanInputHandler
handler = HumanInputHandler()
# Inside your ExternalToolHandler.process_tool_call():
async def process_tool_call(self, tool_name, tool_input, *, room_id=None, **kw):
if tool_name == "AskUserQuestion":
pending = await handler.create(
tool_name, tool_input,
room_id=room_id or "",
tool_call_id=kw.get("tool_call_id", ""),
channel_id=self._channel_id,
)
# ON_USER_INPUT_REQUIRED hook fires via _on_input_required callback
result = await handler.wait(pending.pending_id, timeout=300)
return ToolDecision(approved=False, reason=result)
return ToolDecision(approved=True)
# When user answers:
handler.resolve(pending_id, answer_json)
How It Works¶
Data Flow¶
AI calls AskUserQuestion(questions=[...])
→ HumanInputToolHandler intercepts (tool name matches)
→ HumanInputHandler.create() registers pending request
→ ON_USER_INPUT_REQUIRED hook fires (sync)
→ App broadcasts "question_pending" to frontend via WebSocket
→ HumanInputHandler.wait() blocks (asyncio.Event)
→ User sees question in UI, selects answer
→ App calls handler.resolve(pending_id, answer_json)
→ wait() unblocks, returns answer as tool result
→ AI continues with user's response
Hook Execution Order¶
Three hooks interact during a human-input tool call:
| Order | Hook | Type | Purpose |
|---|---|---|---|
| 1 | BEFORE_TOOL_USE |
Sync | Gate: should this tool run at all? |
| 2 | ON_USER_INPUT_REQUIRED |
Sync | Notify: broadcast question to user |
| 3 | ON_TOOL_CALL |
Sync | Observe: tool completed with result |
BEFORE_TOOL_USE fires before the handler — it can deny the tool (e.g., rate limiting). ON_USER_INPUT_REQUIRED fires inside create() — it notifies the app. ON_TOOL_CALL fires after wait() returns — standard observability.
Parallel Tool Execution¶
When the AI calls multiple tools in one round, asyncio.gather runs them concurrently. If one tool is a human-input tool, it blocks while the others complete normally. The tool loop waits for all results before the next AI generation round.
API Reference¶
HumanInputHandler¶
The core primitive that manages pending requests:
| Method | Signature | Description |
|---|---|---|
create |
async (tool_name, arguments, *, room_id, tool_call_id, channel_id) → PendingInput |
Register a pending request, fires _on_input_required callback |
wait |
async (pending_id, *, timeout=300) → str |
Block until resolved/rejected/timeout |
resolve |
(pending_id, result) → bool |
Unblock with answer. Returns True if found |
reject |
(pending_id, reason="") → bool |
Unblock with error. Returns True if found |
pending |
property → dict[str, PendingInput] |
Snapshot of active pending requests |
HumanInputToolHandler¶
ToolHandler wrapper for the native AIChannel path:
human = HumanInputToolHandler(
tool_names={"AskUserQuestion"}, # Tools that need human input
timeout=300, # Seconds before timeout error
handler=None, # Optional: share a HumanInputHandler
)
- Pass to
AIChannel(human_input_handler=human)— auto-composes with the tool chain - Access the inner handler via
human.handlerforresolve()/reject() - Falls through for non-matching tools (works with
compose_tool_handlers)
PendingInput¶
Mutable dataclass representing a pending request:
| Field | Type | Description |
|---|---|---|
pending_id |
str |
Unique ID for resolving |
tool_name |
str |
Name of the tool |
arguments |
dict |
Tool arguments |
room_id |
str |
Room ID |
tool_call_id |
str |
Provider-assigned tool call ID |
channel_id |
str |
Originating channel |
status |
PendingInputStatus |
PENDING / RESOLVED / REJECTED / TIMED_OUT |
result |
str \| None |
Answer (set on resolve) |
created_at |
datetime |
Timestamp |
PendingInputEvent¶
Frozen dataclass passed to ON_USER_INPUT_REQUIRED hooks:
@kit.hook(HookTrigger.ON_USER_INPUT_REQUIRED, execution=HookExecution.SYNC)
async def on_input(event, ctx):
print(event.pending_id) # ID for resolving
print(event.tool_name) # "AskUserQuestion"
print(event.arguments) # {"questions": [...]}
print(event.room_id) # Room context
Timeout Handling¶
If the user doesn't respond within the timeout:
wait()raisesTimeoutErrorHumanInputToolHandlercatches it and returns a JSON error to the AI:- The AI sees the error and can retry, skip, or inform the user
Full Example¶
See examples/ai_human_input.py for a complete working example with:
- MockAIProvider simulating an
AskUserQuestiontool call HumanInputToolHandlerpausing the tool loop- Async resolution simulating a user answering
ON_USER_INPUT_REQUIREDhook for notifications