- Claude Code pauses before running a risky command or editing a file outside its permissions, and asks “Allow this?” — it only proceeds once approved, and takes a different path when denied.
- Codex approval modes such as suggest and auto-edit decide which actions need confirmation first; it is essentially the same gate.
- AI SDK apps often surface a tool call in the UI when the tool has no
execute, let a human confirm it, then return the result withaddToolResultso the model can continue — this is the most common approval-gate pattern in homegrown applications.
t.parked()), inspect what it is waiting for (t.requireInputRequest()), and answer in place of the human (t.respond()). Approval, rejection, and follow-up information all become paths you can write into an eval.
How a pause is represented: waiting + input.requested
The Turn.status returned from send has three values: "completed" (finished), "failed" (errored), and "waiting" — the turn has not finished; the agent is paused waiting on human input. waiting is not a failure, it is a suspension: once the answer arrives, the same turn continues instead of resending the request or opening a new conversation.
The paused turn must also say what it is waiting for. Each pending question emits one input.requested event with a stable request id, the action where it paused, and optional options such as approve / deny. Both parts matter: waiting tells evals they should answer, and input.requested tells them what to answer.
One complete handshake
t.respond does not use a separate channel — it is just another normal send, and the human decision arrives as structured input:
- The response turn is not a new conversation. It happens on the same session line —
ctx.sessionstill points to the same conversation, and the adapter uses it to recover the state it paused on in the previous turn. - You don’t have to guess the answer from free text.
input.responsescarries{ requestId, optionId }per request (or{ requestId, text }for free-text answers); anoptionIdthat hits one of the request’s options has already been validated on the eval side, so a typo throws instead of silently reaching the application. See Inputs for the different answers for what each answer shape looks like once it reaches the adapter.
On the eval side: three actions
t.parked()asserts that the turn really stopped inwaiting; conversely,t.succeeded()fails on a waiting turn — a pause is an explicit state, never mistaken for “it finished”.t.requireInputRequest(filter)picks exactly one pending request, matching fields such asid,prompt,action, oroptionIds. It throws when 0 or more than 1 requests match — this is how you disambiguate when several requests are paused at once.t.respond(...)answers it and emits the next turn; when several requests of the same kind should get the same answer (for example, approving a batch of changes one by one),t.respondAll(optionId)handles them all at once.
On the adapter side: two obligations, one continuation
HITL asks the adapter to do exactly three things — the first two happen on the paused turn, the third happens on the next turn when the answer arrives:- Return
status: "waiting"honestly when the turn pauses — do not report a suspension as"completed", orparked()stops working andsucceeded()passes when it should not. - Emit one
input.requestedevent for each pending question, with a stableidand as many fields populated as possible — the eval-side checks and matching all depend on them. - On the next
send, hand over the decision before continuing: take the verdict frominput.responsesbyrequestId(do not guess by order), then continue from where the previous turn paused, instead of resending the request.
ctx.session: create an adapter-private createSessionSlot<Pending>(), call ctx.session.set(slot, state) on the paused turn, and ctx.session.take(slot) on the response turn to retrieve it. take(slot) clears it once read. See Write Send step 5 for the full skeleton of how this fits together with streaming.
Like the rest of NiceEval’s capabilities, HITL is not declared with a boolean flag: doing it is having it. If send has returned "waiting" and emitted input.requested, t.respond works; if it has not, the eval fails explicitly at the first parked() or requireInputRequest(), instead of silently passing. See Adapter for how capabilities follow from construction.
Rejection is not a failure
A human veto and a tool error are two different things, and the event stream marks them with different statuses: a call a human rejects gets a tooloperation.finished with status: "rejected", not "failed". That way t.noFailedActions() is not falsely tripped by a legitimate human rejection, and t.calledTool("deploy", { status: "rejected" }) can precisely assert “the call was made, and a human blocked it” — which is exactly the assertion the rejection-branch eval needs to write.
Which agents do not need HITL
Not every agent has this branch. Skip the whole feature when it does not exist — the adapter does not need to handlewaiting, and t.respond never shows up in the eval:
- Agents that finish each turn in one pass: question answering, retrieval, translation, and other send-and-reply services have no step that waits for a person.
- Runtime modes with the approval gate turned off: Claude Code and Codex are HITL products, but as subjects under test in a Sandbox they usually run fully automated (permission confirmation skipped) — that is exactly how the built-in
claude-code,codex, andbubsandbox agents run, so none of them do HITL. - Human review outside the execution loop, such as a ticket system that reviews agent output later. That is another system’s process; it does not go through
send, so an eval cannot observe it and should not try to.
Related reading
- Drive — The full eval-side usage:
t.parked(),t.requireInputRequest(),t.respond()/t.respondAll(). - Adapter — The structured input that answers carry to the adapter, four typical shapes.
- Write Send — The adapter-side practice: typed session slots and the full skeleton for streaming + HITL.
- Connect Your Agent — The integration overview: minimal integration, parameter channels, and the incremental map.