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
DevLoopWorkflowexecution. - 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.
| Caller | Sees a request | Can answer |
|---|---|---|
GitHub token whose github:<login> is in actor_refs | Yes | Yes |
mctl-api OAuth JWT (minted after MCTL sign-in) whose github:<login> is in actor_refs | Yes | Yes |
| Platform admin (GitHub-verified) | Yes, including eligible_actors and invalid_documents | Only if their verified github:<login> is in actor_refs |
Service principal (mctl-agent) | Yes, including eligible_actors and invalid_documents | No: 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 link | As the linked human | As the linked human, if their github:<login> is in actor_refs. See Surface Identity and Relay |
| Anyone else | No: the request is reported as 404 | No |
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 parameter | Values | Default |
|---|---|---|
state | pending, all | pending |
work_item_id | a WorkItem id | none |
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 fullactor_refslist) andinvalid_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=allreturns each request withstate: unknowninstead. - 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"
}| Field | Required | Notes |
|---|---|---|
request_hash | Yes | Echo the request_hash from the read model |
value | Yes | String for free_text (non-blank) and single_choice (one option), non-empty array of options for multi_choice, object for structured |
surface | No | Defaults 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
| HTTP | status / state | Meaning | What the surface should do |
|---|---|---|---|
| 200 | accepted / resolved | The workflow resumed on this answer | Mark the question answered |
| 200 | accepted, detail already accepted | The same answer from the same respondent was already accepted | Treat as success (idempotent retry) |
| 202 | pending_delivery / pending | Signalled, not confirmed within the 5-second confirmation budget | Resubmit the same answer later to check |
| 400 | Error | Malformed request_id or body | Fix the request |
| 401 | Error | Not authenticated | Authenticate the human |
| 403 | rejected / not_eligible | Caller is not GitHub-verified, or not in actor_refs | Tell the human they cannot answer this request |
| 403 | Error | The service principal tried to answer | Submit with the human's credential |
| 404 | Error | No such request, or not visible to the caller | Stop showing it |
| 409 | rejected / superseded | request_hash does not match the current request | Re-read the request; the question changed |
| 409 | rejected / expired | Past expires_at | Stop showing it |
| 409 | rejected / timed_out | The workflow's wait timed out | Stop showing it |
| 409 | rejected / not_pending | The workflow is not waiting on this request, or no longer exists | Stop showing it |
| 409 | rejected / answered | A different answer was recorded first | Show that the question was already answered |
| 409 | rejected / pending | The workflow refused this answer and is still waiting | Let the human correct and resubmit |
| 422 | rejected / invalid_value | The value does not match response_type | Let the human correct and resubmit |
| 500 | Error or rejected | Request files unreadable, or a recorded answer unreadable and dropped | Retry later; report if persistent |
| 503 | pending_delivery / unknown | The answer is recorded but could not be signalled | Resubmit; the next submission delivers the recorded answer first |
| 503 | Error | Nothing 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_deliveriesledger aspending_deliverybefore 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 gets409 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_countbefore delivering. An answer counts as accepted only whenresume_counthas advanced and the workflow still reports thisrequest_id. - Configuration. The ledger uses
HUMAN_INPUT_DB_URL, falling back toAUDIT_DB_URL. With neither, the response endpoint answers 503; there is no in-memory fallback.
Adapter checklist
- List with
GET /api/v1/human-input?state=pending; treat 503 as unknown. - Render
question,reason,response_type,optionsandexpires_at. Offer answer controls only whencan_respondistruefor the human in front of the surface. - 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-Actoronce the human has linked their surface identity (Surface Identity and Relay). Without either, the surface can display requests but cannot collect answers. - Submit with the human's credential, echoing
request_hashand settingsurface. - On 202 or
503 pending_delivery, resubmit the same answer; do not change it. - On
superseded, re-read and re-render. Onexpired,timed_out,not_pending,answeredor 404, stop presenting the request. - Keep clarification and approval visually and functionally separate. Never trigger an approval from an answer.