Add Human-in-the-Loop Controls to an Agent SDK Agent
Add Human-in-the-Loop Controls to an Agent SDK Agent
Add HITL to an existing Agent SDK agent so it can pause high-stakes tool calls for human input
This recipe assumes you already have an agent built with the OpenRouter Agent
SDK and callModel. If you are starting from scratch, first read the
callModel overview to learn
about the Agent SDK.
Goal: Add human-in-the-loop (HITL) controls to an existing Agent SDK agent so one of its tools can auto-resolve routine decisions and pause for human input on high-stakes ones.
Outcome: Your existing callModel loop keeps running normally for routine
tool calls, pauses with status: 'awaiting_hitl' for high-stakes calls,
surfaces the pending call to your UI or API, and resumes after a human supplies
the tool result.
You can give this page to your coding agent as the implementation brief. It should adapt the example names, storage, threshold, and user-review surface to your existing agent rather than scaffold a separate app.
HITL vs requireApproval
Both pause for human input, but they solve different problems:
Use HITL when the decision depends on the input data. Use requireApproval
when you need a human to approve whether a tool should execute. See the Tool
Approval & State
reference for details on approval flows and conditional approval predicates.
Prerequisites
- An existing TypeScript agent that uses
@openrouter/agentandcallModel - An OpenRouter API key configured in that agent’s environment
- A
StateAccessoror a place to persist conversation state - A UI, CLI, queue, or API surface where a human can review pending calls
1. Choose the tool that needs HITL
Pick the tool in your existing agent where the result sometimes needs human judgment. In this example, the agent can approve small payments automatically but must pause before approving larger ones.
A HITL tool uses onToolCalled instead of execute. The hook receives the
parsed input and decides per-call whether to return a tool result immediately
or pause for a human.
Return a value to auto-resolve (like a regular tool). Return null to pause
the loop — the conversation status moves to 'awaiting_hitl' and the call
surfaces to the caller.
outputSchema is required for HITL tools — it validates both the
auto-resolved return value and any human-supplied response. See the
HITLTool type reference
for the full type signature.
2. Add post-processing with onResponseReceived
When a human supplies a response for a paused call, onResponseReceived
fires before the result reaches the model. Use it to enrich, validate, or
transform the raw human input.
Use onResponseReceived when the human review surface does not return the
exact model-facing tool result you want. Common cases include:
- Adding audit metadata such as
reviewedAt,reviewerId, or an internal approval ticket ID - Normalizing UI form fields into the tool’s
outputSchema - Validating the human response against your own policy before the model sees it
- Converting an approval, rejection, or edited value into the final tool result
Replace the tool definition from step 1 with this version:
If parsing or onResponseReceived throws, the error is surfaced to the model
as { error: ..., originalOutput: ... }. If omitted, the human-supplied value
passes through directly after schema validation.
3. Add it to your callModel loop and detect a pause
Add the HITL tool to the tools array you already pass to callModel. HITL
resume requires conversation state, so reuse your existing StateAccessor or
add one if your agent is currently stateless.
The snippet below shows the minimum shape with in-memory state for clarity. In
production, back the StateAccessor with your database, Redis, or whatever
storage your agent already uses.
If you need a deterministic smoke test, temporarily force this tool call:
In production, your agent instructions or user request can let the model decide when to call the tool.
When the model invokes the tool and onToolCalled returns null, the result
pauses with status: 'awaiting_hitl'. Check the state after the call
completes, then surface the pending call to the human review surface in your
app.
Illustrative output shape:
4. Resume with human input
Collect the human’s decision and resume by calling callModel again with a
function_call_output item for each paused call.
In the payment example, the human review surface could be as simple as:
- An admin page with Approve and Reject buttons for the pending payment
- A Slack or Discord message where an operator clicks an approval action
- A CLI prompt that asks an internal user to confirm the payment
- A queue worker that waits for a back-office system to write the approval result
In other HITL workflows, the human input might be more than a boolean. A
support escalation tool might collect an edited reply, a deployment tool might
collect a rollback plan, or a data-change tool might collect corrected field
values. Whatever collects the input should return a value that matches the
tool’s outputSchema.
The onResponseReceived hook fires on the human-supplied output before the
model sees it. In this example, it adds a reviewedAt timestamp.
Check your work
- Calls below the threshold auto-resolve without pausing the loop.
- Calls above the threshold pause with
status: 'awaiting_hitl'. pendingToolCallscontains the paused call with itsidandarguments.- Resuming with a
function_call_outputitem continues the conversation. onResponseReceivedtransforms the human response before the model sees it.- Changing the threshold or adding new conditions in
onToolCalleddoes not require changes to the resume flow.