Human-in-the-loop for a Foundry hybrid agent
Pause a run for a person's decision, ask through AMC, and resume the run with the answer.
Before you start`HITL_ENABLED` set to `True` and a consumer that can answer, such as AMC's run page.View as Markdown
Most scheduled skills never need a person. Leave HITL = False unless the skill has a decision a person must make (which of several targets, approval before publish) and a consumer can answer it.
The brain runs unattended, so the pause is host-driven: the brain writes a question file and stops, the shell asks the question in the shape AMC recognises and completes the response, and the next turn applies the answer.
The flow
- The brain writes
<run_dir>/hitl_request.jsonwithrequest_id, a one-sentencequestion, andoptions(a list) if the answer is a choice. It saves its intermediate work in the run directory and stops. - After the turn, the runner reads the file through the pinned run directory. The envelope's status becomes
awaiting_human. - The shell emits steps and markers for the work so far, then a
request_infoitem, then completes the response normally. - AMC shows the question. The answer comes back as a new request with
previous_response_idset to the paused response and afunction_call_outputwhosecall_idis the request id. - The shell detects the resume, recovers the original request, keeps the run directory, and builds
{answers_clause}into the prompt so the brain continues.
The question item
json
{
"type": "function_call",
"name": "request_info",
"call_id": "ledger-choice.7f3a2c91b0de",
"arguments": {
"request_id": "ledger-choice.7f3a2c91b0de",
"request_event": {"type": "request_info", "data": {"question": "Which ledger: EU or US?"}},
"options": ["EU", "US"],
"request": {"mode": "run", "target_date": "2026-10-16", "dry_run": false},
"resume_state": {"mode": "run", "target_date": "2026-10-16", "dry_run": false}
}
}- `call_id` is not equal to `name`. That keeps it out of the step list and lets AMC key the pending request on
call_id. AMC recognisesrequest_infoby its name alone, so the question still shows ifcall_id == name, but keep them different so the pending request has its own key. - The id is unique per pause: the brain's id as the stem, if well formed (otherwise
req), plus a.and 12 random hex characters the host always appends. - `resume_state` carries the original request so the resumed turn never runs on defaults. It is omitted in confidential mode.
The answer
Resume request
{
"previous_response_id": "<paused-response-id>",
"input": [{"type": "function_call_output", "call_id": "ledger-choice.7f3a2c91b0de", "output": "EU"}],
"stream": true
}An answer to a call_id this conversation never asked is ignored. An unrelated function result doesn't count as a resume.
What to add
- `HITL_NOTE` appended to the unattended note: when a decision genuinely needs a person, and only then, write the file, save intermediates, and stop; the answer arrives at the start of the next turn; never ask a question the skill's own rules can answer.
- `HITL_QUESTIONS`: full-match regexes of the questions the agent may ask. In confidential mode a question that doesn't match, or an option over 40 characters or containing a URL or path, is replaced with a fixed prompt.
- Tell the brain to re-derive missing intermediates. A session suspended by the idle timeout between question and answer gives the resumed turn an empty
/work. Put anything small and essential in the question's arguments too.
Design for these
- The question file fails closed. Present but unreadable, malformed, not an object, or without question text makes the run failed. Only a missing file means no pause.
- A turn that never pauses ends green. If the brain simply doesn't write the file, the run ends
okhaving asked nobody.output.hitl_pausedshows it. If the pause is a mandatory approval gate, enforce it in_postprocess: a first turn withhitl_pausedfalse and nothing published is a failed run. - Cost and ceilings are per turn. A resumed turn reloads the skill and pays for its context again, so a two-turn run can spend up to twice
MAX_USD_PER_RUN. - A background caller sees
request_infoin the stored response and statusawaiting_human, and resumes the same way.
Related
Last updated 2026-10-21