Skip to content

Human Input: Architecture ​

Human input is the platform primitive that lets a DevLoop agent stop, ask a human a question, and continue with the answer. It is separate from human approval: an answer is information, never authorization (see Semantics).

This page describes what exists on main today. It does not define the contract.

Normative source

The contract is ADR 013 in mctl-agents: docs/adr/013-human-input-contract.md. The code of record is orchestrator/human_input.py. Where this page and ADR 013 disagree, ADR 013 wins.

The tracking issue (mctl-docs#106) refers to ADR 009. ADR 009 is the ContextSnapshot contract; the human-input contract was numbered 013.

Implementation status (source audit: 2026-10-15)

The request producer, Temporal consumer, response API and surface relay are implemented. The investigator seals model-authored drafts and consumes accepted answers; the GitOps investigate template declares the continuation parameter. Telegram has an adapter, and Portal has backend routes for human input. Availability in a particular installation depends on its deployed versions, surface credentials and identity links; this source audit does not establish live rollout status.

Components ​

ComponentRepositoryRole
Contract (HumanInputRequest, HumanInputResponse)mctl-agents orchestrator/human_input.pySchema, sealing, hashing, validate_response. Stdlib only; runs in the agent sandbox and as pure Temporal workflow code
Request documentmctl-gitops mainOne sealed request.json per proposal. This is the transport
find_human_input_request activitymctl-agents orchestrator/temporal/activities/human_input.pyGitHub contents-API read of the request file
DevLoopWorkflowmctl-agents orchestrator/temporal/workflows/dev_loop.pyParks in WAITING_FOR_INPUT, validates answers, resubmits the investigator
Read model and response endpointmctl-api internal/api/handlers_human_input*.goLists and verifies requests, derives state from the workflow, delivers answers as a Temporal signal
Delivery ledgermctl-api internal/humaninput/ledger_postgres.goPostgres table human_input_deliveries: idempotency and recoverable delivery
Surfacesmctl-telegram, mctl-portal, othersRender requests and submit answers through mctl-api. They hold no state of their own

The request file lives at:

platform-gitops/agents-state/<service>/proposals/<slug>/human-input/request.json

There is at most one request file per proposal. A re-asking investigator overwrites it; after an answer, the continuation removes it and records answered.json. File cleanup and workflow state transitions are separate steps, so file presence alone does not establish that a request is pending. The owning workflow is authoritative for its current state.

Flow ​

  1. The investigator decides it cannot proceed without a human decision and writes one sealed HumanInputRequest to its proposal's human-input/ directory. The model writes a draft; the orchestrator supplies identity and seals the request.
  2. After the investigate step succeeds, DevLoopWorkflow looks up the proposal slug and calls find_human_input_request. A 404 means no request; anything else non-200 is retried.
  3. The workflow parses the document with HumanInputRequest.from_dict, which recomputes question_hash, request_hash and request_id and rejects a mismatch. A malformed document fails the execution with the non-retryable error type human_input_malformed.
  4. Leftovers are skipped (a question already answered in this execution, a request sealed by another workflow or run, one whose created_at is more than 10 minutes (HUMAN_INPUT_PRIOR_RUN_SLACK) before this run started, or one already expired). Otherwise the workflow enters WAITING_FOR_INPUT.
  5. A surface reads the request from mctl-api, shows it, and submits the human's answer to mctl-api, authenticated as that human.
  6. mctl-api checks eligibility, request_hash, expiry and the value type, records the answer in its ledger, and sends the human_input_response signal.
  7. The workflow re-validates the answer with validate_response. On success it increments its resume count, returns to RUNNING, and resubmits mctl-agents-investigate with every answer accepted so far. It then checks again for a new request, bounded by MAX_CLARIFICATION_ROUNDS.

The clarification branch runs only on executions that pass workflow.patched("human-input"), so histories recorded before the feature replay unchanged.

No pod held while waiting ​

The wait is a Temporal wait_condition with a timeout. No Argo workflow, Claude Agent SDK session or activity slot is held during the wait. Only the find_human_input_request read before it, and one more investigate submission when a continuation runs, use resources. A loop can stay in WAITING_FOR_INPUT for days at the cost of Temporal history storage.

State model ​

Three separate vocabularies are in play. They must not be confused.

Workflow state (human_input_state query) ​

The Temporal query human_input_state on DevLoopWorkflow returns HumanInputState:

FieldMeaning
stateRUNNING, WAITING_FOR_INPUT or INPUT_TIMED_OUT
request_id, request_hash, question_hashThe request currently or last waited on
expires_atAs sealed in the request
effective_deadlineThe deadline the wait actually uses. Differs from expires_at when a far-future value was clamped
roundProducer-written round number
resume_countAnswers this execution has accepted
rejected_countPayloads this execution refused (invalid, arrived while not waiting, or over the queue cap)

A workflow that never waited returns the all-empty shape (RUNNING, zero counters), so pollers can call the query unconditionally. state is never WAITING_FOR_APPROVAL: that name belongs to the separate approval gate.

When the wait concludes, the workflow records one of three outcomes: answered, timed_out (the loop ends with human input wait expired for request <id>) or abandoned (the operator abandon signal, which wins over an answer racing it).

Read-model state (mctl-api) ​

mctl-api keeps no copy of the state machine. Each read derives a request's state from its sealed expires_at and the owning workflow's human_input_state:

StateDerived when
pendingThe workflow reports WAITING_FOR_INPUT for this request_id and expires_at is in the future. The only state an answer is accepted in
expiredexpires_at has passed, and the workflow is waiting on it or cannot be asked
timed_outThe workflow reports INPUT_TIMED_OUT for this request
resolvedThe workflow reports RUNNING for this request with resume_count > 0
not_pendingThe workflow is waiting on a different request, on none, or no longer exists
unknownThe workflow did not answer, returned no state, or reported an unrecognised state

A workflow that was terminated or failed while parked still answers the query with its last state, so its request reads as pending until expires_at. An answer to it is refused, because signalling a closed execution fails.

Delivery state (mctl-api ledger) ​

The ledger records what mctl-api did with an answer. It is not the request's state:

StateMeaning
pending_deliveryRecorded, not yet confirmed by the workflow. The value is retained so any later call can redeliver it
acceptedThe workflow resumed on this answer. Terminal
rejectedThe workflow refused it, or the request stopped waiting first. Frees the request for another submission

Continuation ​

Every continuation resubmits mctl-agents-investigate with the full set of accepted answers as the human_input_responses parameter, a JSON array with one entry per answer:

json
[
  {
    "request_id": "hir-377a93a48528eb13",
    "request_hash": "sha256:…",
    "value": "option-b",
    "respondent": "github:alice",
    "surface": "telegram",
    "received_at": "2026-09-23T10:15:00Z"
  }
]

The full history is sent, not just the latest answer, so a later round never re-asks an earlier question.

The investigate CWFT declares human_input_responses. The investigator validates the array, renders accepted answers as untrusted continuation context, and records answered request IDs so they are not asked again. See Agent author guide.

Log and audit events ​

DevLoopWorkflow logs the following events. Every one carries ids, hashes, timestamps and counters only, never question, reason or value.

EventEmitted when
human_input.foreign_executionA request was sealed by another workflow id or run, or predates this run; skipped
human_input.stale_expiredA request was already expired when read; skipped as a leftover
human_input.requestedA valid request for this execution was found
human_input.wait_startedThe workflow entered WAITING_FOR_INPUT
human_input.expiry_clampedexpires_at exceeded now + MAX_REQUEST_TTL_SECONDS and was clamped
human_input.deliveredA queued answer payload is about to be validated
human_input.respondedAn answer was validated; accepted is true or false
human_input.resumedAn accepted answer resumed the loop
human_input.timed_outThe deadline passed with no accepted answer
human_input.abandonedThe abandon signal ended the wait
human_input.cancelledThe workflow was cancelled while waiting

There is no human_input.answered event; an accepted answer produces human_input.responded with accepted: true, followed by human_input.resumed.

mctl-api writes audit entries for submissions with the operations human_input.signal_sent, human_input.response_accepted and human_input.response_rejected. They also carry ids only.