run_id.
HITL applies to the two agentic surfaces that run on the shared Composer/Assistant engine: the AI Composer (which edits a workflow graph) and the Assistant (which calls integration tools with no workflow attached). Both pause and resume through the same machinery. The visual Interrupt node (HITL) is a related-but-separate pause primitive inside the workflow engine — see Two kinds of pause for why they do not share a resume contract.
A HITL question travels over the run’s SSE stream as a
user_input_request event. You answer it with a separate REST POST (or an SDK resume() call), never by writing back onto the SSE channel. The SSE channel is one-way, server to client.The HITL lifecycle
A run pauses when the agent decides it needs your input and calls a HITL tool. The pause is server-side: the run’s coroutine suspends, the open question is recorded, and the SSE stream goes quiet — there is no terminal frame. You answer, and the run resumes on a freshrun_id.
1
A run is in progress
You started a turn with
POST /composer/chat or POST /assistant/chat and opened the SSE listen stream on the returned run_id.2
The run pauses and emits user_input_request
The agent fires a HITL tool. The engine publishes a
user_input_request SSE event carrying the structured question, sets a pending sentinel in a short-lived server-side store, writes an audit row, flips run status to interrupted, and suspends. The stream stays open with no done/error frame.3
You answer
You send the matching response to
POST /composer/chat/{composer_chat_id}/resume or POST /assistant/chat/{chat_id}/resume (or call resume() in an SDK). The response body carries the request_id, your UserInputResponse, and the llm config.4
The run resumes on a NEW run_id
The resume endpoint mints a fresh
run_id and returns a new stream_url. The original listen stream does not carry the resumed events — you must open a new SSE stream on the new run_id. The chat’s thread_id stays the same throughout.5
The run finishes (or pauses again)
The new run streams to a terminal
done/error, or pauses again with another user_input_request. A chat holds at most one open question at a time.Two kinds of pause
ModuleX has two distinct pause-and-resume contracts. They use different event names, different resume endpoints, and differentrun_id semantics. Do not conflate them.
This page documents the chat HITL contract. For the workflow interrupt node and its resume, see Interrupt node (HITL).
The question — UserInputRequest
When a run pauses, the engine emits one user_input_request SSE event. Its payload nests one level deeper than other run events: the SSE frame is {"type": "user_input_request", "data": <UserInputRequest>}, so the question itself lives at data.data, not data. Reading data.kind directly returns nothing — read data.data.kind.
The SDKs hide this nesting. In Python,
user_input_request_from_event(event.data) returns the parsed question. In JavaScript, the user_input_request event is typed so event.data is already the UserInputRequest. See Streaming & human-in-the-loop in the SDKs.UserInputRequest, regardless of kind, carries these base fields:
string
required
The unique id that binds this question to its answer. You echo it back in your response. It is prefixed by tool for readability (
choice-, multi-, yesno-, text-, cred-, exec-).string
required
The question text, in Markdown.
string
required
The discriminator. One of
single_choice, multi_choice, yes_no, free_text, credential_request.boolean
default:"true"
Whether an answer is required. HITL tools set this to
false so the app can show an inline skip control; a skipped answer is the skipped response kind.boolean
default:"false"
Whether a free-text answer is accepted alongside the structured options.
object | null
default:"null"
Optional extra context the app can render with the question.
integer | null
default:"null"
An advisory hint for how long the question is expected to stay open. The hard limit is the pending sentinel’s 7-day TTL (see Pending state and timeouts).
Request kinds
There are exactly five request kinds. The kind-specific fields are added on top of the base fields above.- single_choice
- multi_choice
- yes_no
- free_text
- credential_request
Pick exactly one of a list of options (1–10 options).Answer with a
ChoiceOption[]
required
Between 1 and 10 options. Each
ChoiceOption is {value, label, description?, icon?, badge?}.single_choice response.user_input_request frame on the SSE stream looks like this (note the nested data):
The answer — UserInputResponse
You answer by sending a UserInputResponse. It is discriminated on its own kind field, and there are seven response kinds — two more than the request side, because one request kind (credential_request) maps to two outcomes (credential_added / credential_failed), and skipped is a response-only kind that any question with required: false can receive.
Response kinds
- single_choice
- multi_choice
- yes_no
- free_text
- credential_added
- credential_failed
- skipped
Request-to-response map
One request kind, one response kind.
One request kind, one response kind.
One request kind, one response kind.
One request kind, one response kind.
One request kind, three possible response kinds.
Any question with
required: false can also be answered with skipped.Resuming a run
You resume by sending the answer to the resume endpoint for the surface. Both surfaces share the same request body and the same response shape.Request body
string
required
The
request_id of the open question. It must match the chat’s pending sentinel, or the call returns a 410.UserInputResponse
required
Your answer, discriminated on
kind. See Response kinds. A malformed response fails validation with a 422.object
required
The model configuration to rebuild the agent with on resume:
{integration_name, provider_id, model_id, credential_id?}. The field is optional in the underlying schema but the endpoint returns a 400 if it is absent — checkpoints persist the run state, not the model instance, so resume must be told which model to use. Treat it as required.Response
A successful resume returns200 with a status of resuming and, critically, a new run_id plus the stream_url to open for it.
string
resuming.string
The chat the run belongs to. The Composer endpoint returns
composer_chat_id; the Assistant endpoint returns chat_id.string
The new
run_id. The pre-resume run_id is finished; the resumed work streams under this new id.string
The conversation thread id, equal to the chat id. It is stable across the whole conversation and does not change on resume.
string
The path to the new run’s SSE stream, of the form
/composer/chat/{id}/listen/{new_run_id} (or the Assistant equivalent). Open this to watch the resumed run.Resume an open question
This example answers ayes_no question. Substitute the matching response kind for other question kinds.
For the Assistant, call
client.assistant.resume(chatId, ...) against POST /assistant/chat/{chat_id}/resume. The only signature difference is that the Assistant SDK resume() requires llm (no default), whereas the Composer SDK resume() defaults llm to null — but the backend rejects a missing llm on both with a 400, so always send it. See Streaming & human-in-the-loop in the SDKs for the full SDK contract.Credential requests
Acredential_request question is how the Assistant (and the Composer) asks you to connect an account it needs to call a tool. It has two completion paths.
Form-based credentials (API key, bearer token, custom)
Form-based credentials (API key, bearer token, custom)
You collect the credential, store it (creating a
credential_id), and resume with a credential_added response. On resume the endpoint runs a preflight test of the new credential before the agent re-enters: if the test fails, the engine swaps your credential_added for a credential_failed with error_code: invalid_credentials and retryable: true, so the agent gets a structured failure instead of committing to a broken credential. Integrations with no test endpoint pass the preflight as a no-op.OAuth2 credentials — auto-resume
OAuth2 credentials — auto-resume
For an
oauth2 auth option, you open the provider’s OAuth flow. When the OAuth callback completes, ModuleX resumes the run for you — no explicit resume() call is needed. The callback runs the same authorization guard, publishes a run_resumed event on the old run’s SSE channel carrying the new run_id, and schedules the resume. Watch for run_resumed to learn the new run_id and switch your stream to it. This auto-resume is why connecting an OAuth account from inside a chat just works. See also Using tools.Pending state and timeouts
A chat holds at most one open HITL question at a time. The engine enforces this so two interrupt-gated tools cannot fire in one step.- One question per chat. While a question is pending, starting a new turn with
POST /composer/chatorPOST /assistant/chatreturns a 409 (This chat has a pending question; answer it first). Answer or cancel the open question first. - Pending sentinel. The open question is tracked by a sentinel in a short-lived server-side store with a 7-day TTL. After it expires the question can no longer be answered.
- Re-render after refresh. Fetching the chat with
GET /composer/chat/{id}orGET /assistant/chat/{id}rehydrates the open question intopending_user_input_request, so the app can re-show the widget after a page reload. - Status check.
GET /composer/chat/{id}/status(and the Assistant equivalent) reportsawaiting_inputandpending_request_idwhile a run is paused. Use it to detect a paused run without holding an SSE connection open. - Audit trail. Each question writes a
composer_interrupt_auditrow whoseoutcomemoves throughpending->resumed/cancelled/expired/failed.
POST /composer/chat/{id}/cancel (or the Assistant equivalent) clears the pending sentinel and flips the audit row to cancelled, so the question is not re-presented on reload.
Errors
The resume endpoints can return the following. Ownership failures return an identical 404 whether the chat does not exist or is simply not in your org — there is no existence leak.The
llm config is missing. (Standard {"detail": "..."} envelope.)You are not the user who triggered the question — only that user may answer it. Also returned if you lack the required org role.
The chat does not exist or is not in your organization.
Returned by the
chat endpoints (not resume) when a question is already pending or a run is already in progress on the chat.The
request_id is no longer pending — it was already answered, cancelled, expired, or another caller won the resume race. (The SDKs surface 410 as the base error class; there is no dedicated GoneError.)The
response body failed discriminated-union validation (wrong or malformed kind).Composer and Assistant are gated surfaces. Starting or resuming a turn can hit the billing admission gate and return a flat
DenialEnvelope of the shape {code, layer, key, current, limit, reason} — 402 for credit/wallet, 403 for quota, 429 for rate. See Errors & status codes and Usage gating & limits.The two race-loss outcomes both resolve to 410: if two clients answer the same question, the atomic compare-and-delete of the pending sentinel lets exactly one win, and the loser sees a 410.
Authentication
Every request on this page authenticates the same way as the rest of the ModuleX API: an API key as a bearer token plus the organization context header.member role is retired. See Authentication and Roles & permissions.
Related
Interrupt node (HITL)
The workflow-engine pause primitive — a separate resume contract that reuses the same
run_id.Streaming & HITL in the SDKs
The
listen() and resume() methods in the JavaScript and Python SDKs.SSE run streaming
The wire format and event taxonomy that carries the
user_input_request frame.Workflows & runs
The three distinct run-id identities and how a run relates to a thread.