Skip to content

Human Input: Surface Adapter Guide ​

A surface is any place a human sees a pending question and types an answer: Telegram, the Portal, a GitHub comment, an MCP client. This guide describes how a surface integrates with the mctl-api read model and response endpoint.

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.

Surfaces consume, never own, state ​

This rule is normative for every surface:

  • A surface must read pending requests and their state from mctl-api, and must not treat a local copy as the source of truth. The request content is owned by mctl-gitops; whether it is pending is owned by the DevLoopWorkflow execution.
  • A surface must submit answers through POST /api/v1/human-input/{request_id}/response. It must not signal Temporal, write to mctl-gitops, or call the approval endpoint as a result of an answer.
  • A surface may cache a request for display, and must re-read it before presenting it as answerable.
  • A surface must not invent a respondent. The respondent is whoever authenticated the submission.

Identity ​

mctl-api decides who may see and who may answer from how the caller authenticated. See Authentication for how callers sign in.

CallerSees a requestCan answer
GitHub token whose github:<login> is in actor_refsYesYes
mctl-api OAuth JWT (minted after MCTL sign-in) whose github:<login> is in actor_refsYesYes
Platform admin (GitHub-verified)Yes, including eligible_actors and invalid_documentsOnly if their verified github:<login> is in actor_refs
Service principal (mctl-agent)Yes, including eligible_actors and invalid_documentsNo: 403. A human answer relayed by a machine credential cannot be told apart from the machine answering
Surface principal (surface:telegram, surface:portal) relaying through a verified linkAs the linked humanAs the linked human, if their github:<login> is in actor_refs. See Surface Identity and Relay
Anyone elseNo: the request is reported as 404No

The consequence for adapter design: a surface submits each answer either with the answering human's own GitHub-verified credential, or through its own surface principal relaying for a human who has linked their surface identity (Surface Identity and Relay). The respondent is recorded as github:<login>, taken from authentication. The request body has no respondent field and unknown fields are rejected.

Endpoints ​

All three require authentication. The two reads share the general authenticated rate limit; the response endpoint is in the write group (20 requests per minute per user).

GET /api/v1/human-input ​

Lists the requests the caller may see.

Query parameterValuesDefault
statepending, allpending
work_item_ida WorkItem idnone
json
{
  "items": [
    {
      "request_id": "hir-377a93a48528eb13",
      "request_hash": "sha256:…",
      "request_version": 1,
      "round": 1,
      "work_item_id": "…",
      "service": "mctl-api",
      "proposal": "issue-261-…",
      "workflow_id": "dev-loop-mctlhq-mctl-api-261",
      "agent": "issue-investigator",
      "question": "…",
      "reason": "…",
      "response_type": "single_choice",
      "options": ["option-a", "option-b"],
      "context_refs": ["github:mctlhq/mctl-api#261"],
      "audience": "repo_operators",
      "can_respond": true,
      "created_at": "…",
      "expires_at": "…",
      "state": "pending"
    }
  ],
  "count": 1
}
  • eligible_actors (the full actor_refs list) and invalid_documents (request files that failed seal verification) are returned only to admins and the service principal.
  • Internal correlation data (execution hashes, the target commit, Argo names, question_hash) is never returned.
  • With state=pending, the endpoint answers 503 rather than a short or empty list when pending cannot be determined for any candidate (no Temporal client, a query failed or timed out). Treat 503 as "unknown", never as "nothing is waiting". state=all returns each request with state: unknown instead.
  • A request file that fails verification is never listed.

GET /api/v1/human-input/{request_id} ​

Returns one request in the same shape, with its state. request_id must match ^hir-[0-9a-f]{16}$ (otherwise 400). A request the caller may not see returns 404, not 403.

POST /api/v1/human-input/{request_id}/response ​

json
{
  "request_hash": "sha256:…",
  "value": "option-b",
  "surface": "telegram"
}
FieldRequiredNotes
request_hashYesEcho the request_hash from the read model
valueYesString for free_text (non-blank) and single_choice (one option), non-empty array of options for multi_choice, object for structured
surfaceNoDefaults to api. Must match ^[a-z][a-z0-9_-]{0,31}$. Provenance only

The body is limited to 64 KiB; unknown fields and trailing data are rejected.

Result body (HumanInputResponseResult):

json
{
  "request_id": "hir-377a93a48528eb13",
  "status": "accepted",
  "state": "resolved",
  "respondent": "github:alice",
  "received_at": "2026-09-23T10:15:00Z"
}

status is one of accepted, pending_delivery, rejected.

Handling outcomes ​

HTTPstatus / stateMeaningWhat the surface should do
200accepted / resolvedThe workflow resumed on this answerMark the question answered
200accepted, detail already acceptedThe same answer from the same respondent was already acceptedTreat as success (idempotent retry)
202pending_delivery / pendingSignalled, not confirmed within the 5-second confirmation budgetResubmit the same answer later to check
400ErrorMalformed request_id or bodyFix the request
401ErrorNot authenticatedAuthenticate the human
403rejected / not_eligibleCaller is not GitHub-verified, or not in actor_refsTell the human they cannot answer this request
403ErrorThe service principal tried to answerSubmit with the human's credential
404ErrorNo such request, or not visible to the callerStop showing it
409rejected / supersededrequest_hash does not match the current requestRe-read the request; the question changed
409rejected / expiredPast expires_atStop showing it
409rejected / timed_outThe workflow's wait timed outStop showing it
409rejected / not_pendingThe workflow is not waiting on this request, or no longer existsStop showing it
409rejected / answeredA different answer was recorded firstShow that the question was already answered
409rejected / pendingThe workflow refused this answer and is still waitingLet the human correct and resubmit
422rejected / invalid_valueThe value does not match response_typeLet the human correct and resubmit
500Error or rejectedRequest files unreadable, or a recorded answer unreadable and droppedRetry later; report if persistent
503pending_delivery / unknownThe answer is recorded but could not be signalledResubmit; the next submission delivers the recorded answer first
503ErrorNothing was recorded (endpoint not configured, ledger unavailable, workflow could not be asked)Retry later

There is no 410 Gone; expiry is reported as 409 with state expired.

Delivery guarantees ​

  • Record before signal. The answer is written to the human_input_deliveries ledger as pending_delivery before the signal is sent. A Temporal outage leaves it recorded and retryable, not lost.
  • First claim wins. There is at most one live delivery per request_id. A different answer submitted later gets 409 answered. If the first delivery was rejected, the request is free again.
  • Any later submission redelivers. When a recorded answer is still pending_delivery, the next submission for that request, from anyone, first pushes the recorded answer through. No background worker is involved.
  • At least once. Two identical submissions racing each other can both signal. The workflow tolerates this: it takes the first valid answer and counts and drops payloads that arrive after it stops waiting.
  • Acceptance is proven, not assumed. mctl-api records the workflow's resume_count before delivering. An answer counts as accepted only when resume_count has advanced and the workflow still reports this request_id.
  • Configuration. The ledger uses HUMAN_INPUT_DB_URL, falling back to AUDIT_DB_URL. With neither, the response endpoint answers 503; there is no in-memory fallback.

Adapter checklist ​

  1. List with GET /api/v1/human-input?state=pending; treat 503 as unknown.
  2. Render question, reason, response_type, options and expires_at. Offer answer controls only when can_respond is true for the human in front of the surface.
  3. Map the surface user to a GitHub-verified human. Either submit with that human's own credential, or relay with the surface principal and X-MCTL-Surface-Actor once the human has linked their surface identity (Surface Identity and Relay). Without either, the surface can display requests but cannot collect answers.
  4. Submit with the human's credential, echoing request_hash and setting surface.
  5. On 202 or 503 pending_delivery, resubmit the same answer; do not change it.
  6. On superseded, re-read and re-render. On expired, timed_out, not_pending, answered or 404, stop presenting the request.
  7. Keep clarification and approval visually and functionally separate. Never trigger an approval from an answer.