Skip to main content
A ModuleX agent run can stop in the middle of its work to ask you a question — pick one of these options, confirm before sending, connect this account — and wait for your answer before it continues. That pause-and-wait pattern is human-in-the-loop (HITL). This page is the wire-level reference for it: the structured question the run pushes to you, the structured answer you send back, the endpoint and SDK methods that deliver that answer, and the fact that resuming a run mints a new 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 fresh run_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 different run_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.
Every 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.
Pick exactly one of a list of options (1–10 options).
ChoiceOption[]
required
Between 1 and 10 options. Each ChoiceOption is {value, label, description?, icon?, badge?}.
Answer with a single_choice response.
A raw 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.
The response kind must match the question. Answer a yes_no question with a yes_no response, a single_choice question with a single_choice response, and so on. A credential_request is the exception: it accepts credential_added, credential_failed, or skipped. A mismatched or malformed response fails body validation with a 422.

Response kinds

string
required
single_choice.
string | null
The value of the chosen option.
string | null
A free-text answer, used when the question set allow_free_text: true.

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 returns 200 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.
A resume mints a new run_id. The original listen stream does not carry the resumed events. After you call resume(), you must open a new SSE stream on the returned run_id. This is the headline difference from the workflow Interrupt node resume, which reuses the same run_id. The three distinct identities — per-run run_id, per-conversation thread_id, and the durable run row — are explained in Workflows & runs.

Resume an open question

This example answers a yes_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

A credential_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.
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.
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.
The refreshOAuth2 flow for re-minting an expired OAuth2 token is a known limitation — the supporting route is not wired. To recover an expired OAuth2 credential, reconnect the integration rather than relying on a silent refresh.

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/chat or POST /assistant/chat returns 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} or GET /assistant/chat/{id} rehydrates the open question into pending_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) reports awaiting_input and pending_request_id while 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_audit row whose outcome moves through pending -> resumed / cancelled / expired / failed.
To abandon a paused run instead of answering it, cancel it: 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.
Composer and Assistant endpoints require the caller to hold the owner or admin role in the organization; the member role is retired. See Authentication and Roles & permissions.

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.