> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modulex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Human-in-the-loop (HITL) resume

> The complete HITL pause/resume contract for ModuleX Composer and Assistant runs: the five request kinds, the seven response kinds, the resume endpoint and SDK resume() methods, the new run_id minted on resume, and every error code.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

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](/concepts/ai-composer) (which edits a workflow graph) and the [Assistant](/concepts/assistant) (which calls integration tools with no workflow attached). Both pause and resume through the same machinery. The visual [Interrupt node (HITL)](/workflow-builder/nodes/interrupt) is a related-but-separate pause primitive inside the workflow engine — see [Two kinds of pause](#two-kinds-of-pause) for why they do not share a resume contract.

<Note>
  A HITL question travels over the run's [SSE stream](/realtime/sse-streaming) 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.
</Note>

## 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`.

<Steps>
  <Step title="A run is in progress">
    You started a turn with `POST /composer/chat` or `POST /assistant/chat` and opened the [SSE listen stream](/realtime/sse-streaming) on the returned `run_id`.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

<MediaEmbed id="MX-MEDIA-2080" type="image" caption={"HITL pause/resume sequence diagram across SSE and REST."} />

### 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.

|                       | Chat HITL (this page)                                                 | Workflow interrupt node                                                                                          |
| --------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| Surfaces              | [Composer](/concepts/ai-composer), [Assistant](/concepts/assistant)   | [Workflow runs](/workflow-builder/execution/running) via the [Interrupt node](/workflow-builder/nodes/interrupt) |
| SSE event             | `user_input_request`                                                  | `interrupt`                                                                                                      |
| Question shape        | `UserInputRequest` (5 kinds)                                          | free-form `data` plus optional `resume_schema`                                                                   |
| Resume endpoint       | `POST /composer/chat/{id}/resume`, `POST /assistant/chat/{id}/resume` | `POST /workflows/resume/{thread_id}`                                                                             |
| `run_id` after resume | a **new** `run_id` is minted                                          | the **same** `run_id` is reused                                                                                  |
| SDK method            | `composer.resume(...)`, `assistant.resume(...)`                       | `executions.resume(...)`                                                                                         |

This page documents the chat HITL contract. For the workflow interrupt node and its resume, see [Interrupt node (HITL)](/workflow-builder/nodes/interrupt).

## 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`.

<Note>
  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](/sdks/streaming-hitl).
</Note>

Every `UserInputRequest`, regardless of kind, carries these base fields:

<ResponseField name="request_id" type="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-`).
</ResponseField>

<ResponseField name="message" type="string" required>
  The question text, in Markdown.
</ResponseField>

<ResponseField name="kind" type="string" required>
  The discriminator. One of `single_choice`, `multi_choice`, `yes_no`, `free_text`, `credential_request`.
</ResponseField>

<ResponseField name="required" type="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.
</ResponseField>

<ResponseField name="allow_free_text" type="boolean" default="false">
  Whether a free-text answer is accepted alongside the structured options.
</ResponseField>

<ResponseField name="context" type="object | null" default="null">
  Optional extra context the app can render with the question.
</ResponseField>

<ResponseField name="timeout_hint_seconds" type="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](#pending-state-and-timeouts)).
</ResponseField>

### Request kinds

There are exactly **five** request kinds. The kind-specific fields are added on top of the base fields above.

<Tabs>
  <Tab title="single_choice">
    Pick exactly one of a list of options (1–10 options).

    <ResponseField name="options" type="ChoiceOption[]" required>
      Between 1 and 10 options. Each `ChoiceOption` is `{value, label, description?, icon?, badge?}`.
    </ResponseField>

    Answer with a `single_choice` response.
  </Tab>

  <Tab title="multi_choice">
    Pick zero or more of a list of options (1–20 options).

    <ResponseField name="options" type="ChoiceOption[]" required>
      Between 1 and 20 options.
    </ResponseField>

    <ResponseField name="min_selections" type="integer" default="0">
      Minimum number of selections.
    </ResponseField>

    <ResponseField name="max_selections" type="integer | null" default="null">
      Maximum number of selections, or `null` for no cap.
    </ResponseField>

    Answer with a `multi_choice` response.
  </Tab>

  <Tab title="yes_no">
    A boolean confirmation.

    <ResponseField name="yes_label" type="string" default="Yes">
      Label for the affirmative choice.
    </ResponseField>

    <ResponseField name="no_label" type="string" default="No">
      Label for the negative choice.
    </ResponseField>

    Answer with a `yes_no` response.
  </Tab>

  <Tab title="free_text">
    A free-text answer.

    <ResponseField name="placeholder" type="string | null" default="null">
      Input placeholder text.
    </ResponseField>

    <ResponseField name="multiline" type="boolean" default="false">
      Whether the input is multi-line.
    </ResponseField>

    <ResponseField name="min_length" type="integer" default="0">
      Minimum answer length.
    </ResponseField>

    <ResponseField name="max_length" type="integer | null" default="null">
      Maximum answer length, or `null` for no cap.
    </ResponseField>

    Answer with a `free_text` response.
  </Tab>

  <Tab title="credential_request">
    The agent needs you to connect an account before it can call a tool. See [Credential requests](#credential-requests) for the full flow.

    <ResponseField name="integration_name" type="string" required>
      The machine name of the integration, for example `github`.
    </ResponseField>

    <ResponseField name="integration_display_name" type="string" required>
      The human-readable integration name, for example `GitHub`.
    </ResponseField>

    <ResponseField name="integration_logo" type="string | null" default="null">
      Optional logo URL for the integration.
    </ResponseField>

    <ResponseField name="auth_options" type="CredentialAuthOption[]" required>
      The ways you can authenticate. Each `CredentialAuthOption` has `auth_type` (one of `oauth2`, `api_key`, `bearer_token`, `modulex_key`, `custom`), `display_name`, optional `fields`, optional `oauth_initiate_endpoint`, optional `setup_instructions`, and `test_supported` (default `false`).
    </ResponseField>

    <ResponseField name="pending_node_name" type="string | null" default="null">
      The node awaiting the credential, when applicable.
    </ResponseField>

    Answer with a `credential_added`, `credential_failed`, or `skipped` response.
  </Tab>
</Tabs>

A raw `user_input_request` frame on the SSE stream looks like this (note the nested `data`):

```json theme={null}
{
  "type": "user_input_request",
  "data": {
    "kind": "yes_no",
    "request_id": "yesno-ab12cd34",
    "message": "Send the welcome email now?",
    "required": true,
    "allow_free_text": false,
    "yes_label": "Send it",
    "no_label": "Not yet"
  }
}
```

## 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.

<Warning>
  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**.
</Warning>

### Response kinds

<Tabs>
  <Tab title="single_choice">
    <ResponseField name="kind" type="string" required>
      `single_choice`.
    </ResponseField>

    <ResponseField name="selected_value" type="string | null">
      The `value` of the chosen option.
    </ResponseField>

    <ResponseField name="free_text" type="string | null">
      A free-text answer, used when the question set `allow_free_text: true`.
    </ResponseField>
  </Tab>

  <Tab title="multi_choice">
    <ResponseField name="kind" type="string" required>
      `multi_choice`.
    </ResponseField>

    <ResponseField name="selected_values" type="string[]" required>
      The `value`s of the chosen options.
    </ResponseField>
  </Tab>

  <Tab title="yes_no">
    <ResponseField name="kind" type="string" required>
      `yes_no`.
    </ResponseField>

    <ResponseField name="answer" type="boolean" required>
      `true` for yes, `false` for no.
    </ResponseField>
  </Tab>

  <Tab title="free_text">
    <ResponseField name="kind" type="string" required>
      `free_text`.
    </ResponseField>

    <ResponseField name="text" type="string" required>
      The free-text answer.
    </ResponseField>
  </Tab>

  <Tab title="credential_added">
    Sent after you successfully connect an account in response to a `credential_request`. The resume endpoint re-tests the credential before the agent commits to it (see [Credential requests](#credential-requests)).

    <ResponseField name="kind" type="string" required>
      `credential_added`.
    </ResponseField>

    <ResponseField name="credential_id" type="string" required>
      The id of the newly stored credential.
    </ResponseField>

    <ResponseField name="integration_name" type="string" required>
      The integration the credential is for.
    </ResponseField>

    <ResponseField name="auth_type" type="string" required>
      The auth type used, for example `oauth2` or `api_key`.
    </ResponseField>
  </Tab>

  <Tab title="credential_failed">
    Sent when connecting the account failed, so the agent can react instead of blocking on a broken credential.

    <ResponseField name="kind" type="string" required>
      `credential_failed`.
    </ResponseField>

    <ResponseField name="integration_name" type="string" required>
      The integration the attempt was for.
    </ResponseField>

    <ResponseField name="auth_type" type="string" required>
      The auth type that was attempted.
    </ResponseField>

    <ResponseField name="error_code" type="string" required>
      One of `oauth_denied`, `oauth_provider_error`, `invalid_credentials`, `network_error`, `popup_closed`, `timeout`, `unknown`.
    </ResponseField>

    <ResponseField name="error_message" type="string" required>
      A human-readable failure message.
    </ResponseField>

    <ResponseField name="retryable" type="boolean" default="true">
      Whether the connection attempt can be retried.
    </ResponseField>

    <ResponseField name="provider_details" type="object | null" default="null">
      Optional provider-specific detail (truncated to bound the audit row).
    </ResponseField>
  </Tab>

  <Tab title="skipped">
    Sent when you dismiss a non-required question.

    <ResponseField name="kind" type="string" required>
      `skipped`.
    </ResponseField>

    <ResponseField name="reason" type="string | null" default="null">
      An optional reason for skipping.
    </ResponseField>
  </Tab>
</Tabs>

### Request-to-response map

<ResponseField name="single_choice → single_choice">
  One request kind, one response kind.
</ResponseField>

<ResponseField name="multi_choice → multi_choice">
  One request kind, one response kind.
</ResponseField>

<ResponseField name="yes_no → yes_no">
  One request kind, one response kind.
</ResponseField>

<ResponseField name="free_text → free_text">
  One request kind, one response kind.
</ResponseField>

<ResponseField name="credential_request → credential_added | credential_failed | skipped">
  One request kind, three possible response kinds.
</ResponseField>

<ResponseField name="any non-required question → skipped">
  Any question with `required: false` can also be answered with `skipped`.
</ResponseField>

## 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.

| Surface   | Endpoint                                        |
| --------- | ----------------------------------------------- |
| Composer  | `POST /composer/chat/{composer_chat_id}/resume` |
| Assistant | `POST /assistant/chat/{chat_id}/resume`         |

### Request body

<ParamField body="request_id" type="string" required>
  The `request_id` of the open question. It must match the chat's pending sentinel, or the call returns a **410**.
</ParamField>

<ParamField body="response" type="UserInputResponse" required>
  Your answer, discriminated on `kind`. See [Response kinds](#response-kinds). A malformed response fails validation with a **422**.
</ParamField>

<ParamField body="llm" type="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.
</ParamField>

### 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.

<ResponseField name="status" type="string">
  `resuming`.
</ResponseField>

<ResponseField name="composer_chat_id / chat_id" type="string">
  The chat the run belongs to. The Composer endpoint returns `composer_chat_id`; the Assistant endpoint returns `chat_id`.
</ResponseField>

<ResponseField name="run_id" type="string">
  The **new** `run_id`. The pre-resume `run_id` is finished; the resumed work streams under this new id.
</ResponseField>

<ResponseField name="thread_id" type="string">
  The conversation thread id, equal to the chat id. It is stable across the whole conversation and does not change on resume.
</ResponseField>

<ResponseField name="stream_url" type="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.
</ResponseField>

<Warning>
  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](/workflow-builder/nodes/interrupt) 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](/concepts/workflows-and-runs).
</Warning>

### Resume an open question

This example answers a `yes_no` question. Substitute the matching response `kind` for other question kinds.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/composer/chat/f2c1.../resume \
    -H "Authorization: Bearer mx_live_..." \
    -H "X-Organization-ID: org_123" \
    -H "Content-Type: application/json" \
    -d '{
      "request_id": "yesno-ab12cd34",
      "response": { "kind": "yes_no", "answer": true },
      "llm": {
        "integration_name": "openai",
        "provider_id": "openai",
        "model_id": "gpt-4o"
      }
    }'
  ```

  ```python Python theme={null}
  from modulex import Modulex
  from modulex.types.realtime import YesNoResponse, ComposerLLMConfig

  async with Modulex(api_key="mx_live_...", organization_id="org_123") as client:
      llm = ComposerLLMConfig(
          integration_name="openai", provider_id="openai", model_id="gpt-4o"
      )
      resumed = await client.composer.resume(
          composer_chat_id,
          request_id="yesno-ab12cd34",
          response=YesNoResponse(answer=True),
          llm=llm,
      )
      # resumed.run_id is a NEW run — open a fresh stream on it.
      async with client.composer.listen(composer_chat_id, resumed.run_id) as stream:
          async for event in stream:
              if event.is_terminal:
                  break
  ```

  ```javascript JavaScript theme={null}
  import { Modulex } from "modulex-js";

  const client = new Modulex({
    apiKey: "mx_live_...",
    organizationId: "org_123",
  });

  const resumed = await client.composer.resume(composerChatId, {
    requestId: "yesno-ab12cd34",
    response: { kind: "yes_no", answer: true },
    llm: { integration_name: "openai", provider_id: "openai", model_id: "gpt-4o" },
  });

  // resumed.run_id is a NEW run — open a fresh stream on it.
  for await (const evt of client.composer.listen(composerChatId, resumed.run_id)) {
    if (evt.type === "done" || evt.type === "error") break;
  }
  ```
</CodeGroup>

<Note>
  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](/sdks/streaming-hitl) for the full SDK contract.
</Note>

## Credential requests

A `credential_request` question is how the [Assistant](/concepts/assistant) (and the Composer) asks you to connect an account it needs to call a tool. It has two completion paths.

<AccordionGroup>
  <Accordion title="Form-based credentials (API key, bearer token, custom)" icon="key">
    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.
  </Accordion>

  <Accordion title="OAuth2 credentials — auto-resume" icon="arrows-rotate">
    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](/assistant/using-tools).
  </Accordion>
</AccordionGroup>

<Warning>
  The `refreshOAuth2` flow for re-minting an expired OAuth2 token is a [known limitation](/reference/known-limitations) — the supporting route is not wired. To recover an expired OAuth2 credential, reconnect the integration rather than relying on a silent refresh.
</Warning>

## 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.

<ResponseField name="400 Bad Request">
  The `llm` config is missing. (Standard `{"detail": "..."}` envelope.)
</ResponseField>

<ResponseField name="403 Forbidden">
  You are not the user who triggered the question — only that user may answer it. Also returned if you lack the required org role.
</ResponseField>

<ResponseField name="404 Not Found">
  The chat does not exist or is not in your organization.
</ResponseField>

<ResponseField name="409 Conflict">
  Returned by the `chat` endpoints (not resume) when a question is already pending or a run is already in progress on the chat.
</ResponseField>

<ResponseField name="410 Gone">
  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`.)
</ResponseField>

<ResponseField name="422 Unprocessable Entity">
  The `response` body failed discriminated-union validation (wrong or malformed `kind`).
</ResponseField>

<ResponseField name="402 / 403 / 429 — billing gate">
  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](/api-reference/errors) and [Usage gating & limits](/billing/usage-gating).
</ResponseField>

<Note>
  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.
</Note>

## Authentication

Every request on this page authenticates the same way as the rest of the [ModuleX API](/api-reference/overview): an API key as a bearer token plus the organization context header.

```http theme={null}
Authorization: Bearer mx_live_...
X-Organization-ID: org_123
```

Composer and Assistant endpoints require the caller to hold the **owner** or **admin** role in the organization; the `member` role is retired. See [Authentication](/api-reference/authentication) and [Roles & permissions](/security/roles-permissions).

## Related

<CardGroup cols={2}>
  <Card title="Interrupt node (HITL)" icon="hand" href="/workflow-builder/nodes/interrupt">
    The workflow-engine pause primitive — a separate resume contract that reuses the same `run_id`.
  </Card>

  <Card title="Streaming & HITL in the SDKs" icon="code" href="/sdks/streaming-hitl">
    The `listen()` and `resume()` methods in the JavaScript and Python SDKs.
  </Card>

  <Card title="SSE run streaming" icon="wave-pulse" href="/realtime/sse-streaming">
    The wire format and event taxonomy that carries the `user_input_request` frame.
  </Card>

  <Card title="Workflows & runs" icon="diagram-project" href="/concepts/workflows-and-runs">
    The three distinct run-id identities and how a run relates to a thread.
  </Card>
</CardGroup>
