> ## 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: pause, ask, and resume the Assistant

> How the ModuleX Assistant pauses mid-task to ask a structured question or request approval, the five request kinds and seven response kinds, and exactly how to resume a paused run over REST and the SDKs.

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>;
};

Human-in-the-loop (HITL) is how the Assistant pauses a turn to ask you something — a choice, a piece of text, a yes/no decision, an approval, or a missing credential — and then continues once you answer. While the Assistant is paused it holds exactly one open question, the run is suspended server-side, and no further action runs until you respond. This page covers every request and response kind, the approval gate, the resume contract, and what happens to a paused run.

The Assistant inherits this machinery verbatim from the [AI Composer](/concepts/ai-composer); the wire contract is identical to the [workflow interrupt node](/workflow-builder/nodes/interrupt) HITL question shape and to the SDK [streaming and HITL](/sdks/streaming-hitl) flow. The full pause/resume reference, including the workflow-run variant, lives at [human-in-the-loop (HITL) resume](/realtime/hitl).

<Note>
  HITL is **request-driven**: the Assistant only pauses when its agent calls a HITL tool (`ask_user_*` or `request_credential`). You do not configure interrupt points the way you do in the [interrupt node](/workflow-builder/nodes/interrupt) — the agent decides when an answer or approval is needed.
</Note>

## The pause/resume lifecycle

A turn that needs your input moves through a fixed lifecycle. The Assistant pauses, you answer, and a **new run** continues the same conversation.

<Steps>
  <Step title="The turn starts">
    You send a message to `POST /assistant/chat`. The turn begins streaming over the run's SSE stream. See [streaming responses](/assistant/streaming).
  </Step>

  <Step title="The Assistant asks">
    The agent calls a HITL tool. The run emits a `user_input_request` SSE frame carrying the question, writes a pending audit row, sets a server-side pending sentinel, flips the run status to `interrupted`, and suspends. The stream stays open — there is no terminal frame.
  </Step>

  <Step title="You answer">
    You call `POST /assistant/chat/{chat_id}/resume` with the question's `request_id`, a typed `response`, and your `llm` config. A three-layer guard validates the answer.
  </Step>

  <Step title="A new run resumes">
    Resume mints a **new** `run_id`, re-enters the same `thread_id`, and returns a fresh `stream_url`. You open that new stream to watch the turn finish.
  </Step>

  <Step title="The turn completes">
    The continued run streams the rest of its work and ends with a `done` frame (or pauses again on another question).
  </Step>
</Steps>

```text Frame trace (one HITL turn) theme={null}
data: {"type":"metadata","data":{"run_id":"a1b2c3d4-...","thread_id":"f2b1c0de-...","workflow_id":null,"workflow_type":"composer","timestamp":"2026-06-21T10:00:01+00:00"}}

data: {"type":"response_chunk","data":{"chunk":"Let me confirm before I send."}}

data: {"type":"user_input_request","data":{"kind":"yes_no","request_id":"yesno-req-42","message":"Send the welcome email now?","required":true,"allow_free_text":false,"yes_label":"Send it","no_label":"Not yet"}}

: stream stays open, run paused server-side — NO done/error frame
: client answers: POST /assistant/chat/{chat_id}/resume
: resume returns a NEW run_id; re-listen on /assistant/chat/{chat_id}/listen/{new_run_id}

data: {"type":"done","data":{"response":"Sent.","has_workflow_changes":false,"tool_calls":[],"workflow_tool":null,"usage":{"input_tokens":1200,"output_tokens":120,"total_tokens":1320,"llm_calls":1}}}
```

<Warning>
  A live HITL pause does **not** close the SSE stream and emits **no** terminal frame. The `interrupted` marker is written to history only — it is never published on the live channel. Do not wait for a terminal event after a `user_input_request`; treat the question itself as the signal to stop reading and answer. A later reconnect replays the history (including the `interrupted` marker) so the replay ends cleanly. See [SSE run streaming](/realtime/sse-streaming).
</Warning>

## Request kinds

A HITL question is a `UserInputRequest`, discriminated on its `kind` field. There are exactly **five** request kinds. The Assistant emits one inside the `user_input_request` SSE frame.

<Warning>
  The question payload is **nested one level deeper** than every other run event. The SSE frame is `{"type":"user_input_request","data":<UserInputRequest>}`, so the question lives at `data.data`, not `data`. A naive read of `frame.data.kind` returns nothing — the kind is at `frame.data.data.kind`. The Python SDK ships `user_input_request_from_event(event.data)` to extract it for you.
</Warning>

### Common base fields

Every request kind shares these base fields.

<ResponseField name="request_id" type="string" required>
  The unique id binding this question to its answer. You must echo it back on resume. Ids are prefixed by tool (for example `choice-`, `yesno-`, `text-`, `multi-`, `cred-`). The id is `UNIQUE` on the audit row.
</ResponseField>

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

<ResponseField name="required" type="boolean" default="true">
  Whether an answer is required. When `true`, skipping is not offered.
</ResponseField>

<ResponseField name="allow_free_text" type="boolean" default="false">
  Whether the user may add free text alongside a structured answer (used by `single_choice`).
</ResponseField>

<ResponseField name="context" type="object">
  Optional structured context the agent attaches to the question. May be `null`.
</ResponseField>

<ResponseField name="timeout_hint_seconds" type="integer">
  Optional advisory timeout for the UI. It is a hint only — the authoritative expiry is the pending sentinel's 7-day TTL (see [paused runs](#paused-runs)). May be `null`.
</ResponseField>

### The five kinds

<Tabs>
  <Tab title="single_choice">
    Pick one option from a list.

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

    Answer with a `single_choice` response carrying `selected_value` (and optionally `free_text` when `allow_free_text` is `true`).
  </Tab>

  <Tab title="multi_choice">
    Pick one or more options.

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

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

    <ResponseField name="max_selections" type="integer">
      Maximum number of selections allowed. May be `null` (no maximum).
    </ResponseField>

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

  <Tab title="yes_no">
    A binary decision — commonly used as an approval prompt.

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

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

    Answer with a `yes_no` response carrying `answer` (a boolean).
  </Tab>

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

    <ResponseField name="placeholder" type="string">
      Optional input placeholder.
    </ResponseField>

    <ResponseField name="multiline" type="boolean" default="false">
      Whether to render a multi-line input.
    </ResponseField>

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

    <ResponseField name="max_length" type="integer">
      Maximum character length. May be `null`.
    </ResponseField>

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

  <Tab title="credential_request">
    The Assistant needs a service connected before it can act. See [using tools](/assistant/using-tools) and [authentication and credentials](/integrations/authentication).

    <ResponseField name="integration_name" type="string" required>
      The integration's internal name (for example `github`).
    </ResponseField>

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

    <ResponseField name="integration_logo" type="string">
      Optional logo URL.
    </ResponseField>

    <ResponseField name="auth_options" type="CredentialAuthOption[]" required>
      One or more ways to authenticate. Each `CredentialAuthOption` is `{auth_type, display_name, fields?, oauth_initiate_endpoint?, setup_instructions?, test_supported}`, where `auth_type` is one of `oauth2`, `api_key`, `bearer_token`, `modulex_key`, or `custom`.
    </ResponseField>

    <ResponseField name="pending_node_name" type="string">
      Optional name of the step waiting on the credential.
    </ResponseField>

    Answer with a `credential_added` response (or, if connecting failed, `credential_failed`). For OAuth2, completing the flow auto-resumes the chat — see [OAuth auto-resume](#oauth-auto-resume).
  </Tab>
</Tabs>

## Response kinds

You answer by sending a `UserInputResponse`, discriminated on its `kind` field. There are **seven** response kinds. Note the asymmetry: one request kind (`credential_request`) maps to two response kinds (`credential_added` / `credential_failed`), and `skipped` is a response-only kind with no matching request kind.

<table>
  <thead>
    <tr>
      <th>Response `kind`</th>
      <th>Fields</th>
      <th>Answers request kind</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>`single_choice`</td>
      <td>`selected_value?` (string), `free_text?` (string)</td>
      <td>`single_choice`</td>
    </tr>

    <tr>
      <td>`multi_choice`</td>
      <td>`selected_values` (string array)</td>
      <td>`multi_choice`</td>
    </tr>

    <tr>
      <td>`yes_no`</td>
      <td>`answer` (boolean)</td>
      <td>`yes_no`</td>
    </tr>

    <tr>
      <td>`free_text`</td>
      <td>`text` (string)</td>
      <td>`free_text`</td>
    </tr>

    <tr>
      <td>`credential_added`</td>
      <td>`credential_id`, `integration_name`, `auth_type`</td>
      <td>`credential_request`</td>
    </tr>

    <tr>
      <td>`credential_failed`</td>
      <td>`integration_name`, `auth_type`, `error_code`, `error_message`, `retryable` (default `true`), `provider_details?`</td>
      <td>`credential_request`</td>
    </tr>

    <tr>
      <td>`skipped`</td>
      <td>`reason?` (string)</td>
      <td>any (response-only)</td>
    </tr>
  </tbody>
</table>

<ResponseField name="credential_failed.error_code" type="enum">
  One of `oauth_denied`, `oauth_provider_error`, `invalid_credentials`, `network_error`, `popup_closed`, `timeout`, `unknown`. Sending this kind hands the agent a structured failure so it can recover rather than commit to a broken credential.
</ResponseField>

<Note>
  When you answer with `credential_added`, the resume endpoint runs a server-side **preflight**: it tests the freshly added credential before the agent commits to it. If the test fails, the resume value is swapped to a `credential_failed` response with `error_code: invalid_credentials` and `retryable: true`, so the agent gets a clean failure instead of a broken credential. This is invisible to your code but can change the effective answer.
</Note>

## Approval gates

An **approval gate** is the Assistant stopping for your confirmation before a sensitive or destructive action — for example, before sending an email or deleting a record. Mechanically, an approval is a HITL question (most often a `yes_no` request) that the agent raises ahead of the risky tool call; the action runs only after you answer affirmatively.

Stopping for approval before sensitive actions is a **paid-plan capability** ("manage approvals"), enforced by the Assistant's tool-execution gating policy — distinct from the [billing admission gate](/billing/usage-gating) that meters each turn. Read-only actions generally run without a gate; write or destructive actions are gated. The set of gated actions is governed by ModuleX, not configured per chat.

<Note>
  Approval is not a separate request kind. It is a regular `yes_no` (or `single_choice`) question with approval-shaped labels. Handle it like any other HITL question: stop reading the stream, present the choice, and resume with the user's decision.
</Note>

<CardGroup cols={2}>
  <Card title="Who can approve" icon="user-check" href="/security/roles-permissions">
    Only the user who triggered the question may answer it — including approvals. A different user gets a `403`. The retired `member` role cannot use the Assistant at all.
  </Card>

  <Card title="Limits and entitlements" icon="gauge" href="/assistant/permissions-and-limits">
    Which plans include managed approvals, and the usage limits that apply to Assistant turns.
  </Card>
</CardGroup>

## Responding and resuming

You resume a paused chat by answering its open question. Resume mints a **new** `run_id`; the original stream does not carry the continued events — you must open the new `stream_url`.

### `POST /assistant/chat/{chat_id}/resume`

Authenticate every request with `Authorization: Bearer mx_live_…` plus `X-Organization-ID`, and the owner or admin role in that organization — see [authentication](/api-reference/authentication). The caller must be the same user who triggered the question.

#### Request body

<ParamField body="request_id" type="string" required>
  The paused question's `request_id`, taken from the `user_input_request` frame. It must match the current pending sentinel exactly.
</ParamField>

<ParamField body="response" type="UserInputResponse" required>
  The typed answer, discriminated on `kind` (one of the seven [response kinds](#response-kinds)). A response that fails discriminated-union validation returns `422`.
</ParamField>

<ParamField body="llm" type="object" required>
  The LLM selection used to rebuild the chat model on resume. Although the underlying field is schema-optional, the Assistant endpoint **rejects a missing `llm` with `400`** — it is required in practice, because the graph persists state but not the in-process model. Keys: `integration_name`, `provider_id`, `model_id`, and optional `credential_id`.
</ParamField>

#### The three-layer resume guard

Every resume passes three checks before the run continues:

<Steps>
  <Step title="Request match (read-only)">
    The pending sentinel must exist and `request_id` must match it. A missing sentinel or mismatch returns `410` — the question was already answered, cancelled, or expired.
  </Step>

  <Step title="Ownership (read-only)">
    The caller's user id must equal the question's originator. A different user returns `403`.
  </Step>

  <Step title="Atomic claim (compare-and-delete)">
    The pending sentinel is cleared with a `WATCH`/`MULTI`/`EXEC` compare-and-delete. If two answers race, the loser returns `410`.
  </Step>
</Steps>

#### Response

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

<ResponseField name="chat_id" type="string">
  The chat that was resumed.
</ResponseField>

<ResponseField name="run_id" type="string">
  A **new** `run_id`, minted for the continued turn. Re-listen on this id.
</ResponseField>

<ResponseField name="thread_id" type="string">
  The conversation thread id. Equal to `chat_id` and unchanged across the resume.
</ResponseField>

<ResponseField name="stream_url" type="string">
  The SSE URL for the new run: `/assistant/chat/{chat_id}/listen/{new_run_id}`. Open it to watch the turn finish.
</ResponseField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/assistant/chat/f2b1c0de-1111-2222-3333-444455556666/resume \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: org_your_organization_id" \
    -H "Content-Type: application/json" \
    -d '{
      "request_id": "yesno-req-42",
      "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 (
      user_input_request_from_event,
      YesNoResponse,
      ComposerLLMConfig,
  )

  client = Modulex(
      api_key="mx_live_your_api_key",
      organization_id="org_your_organization_id",
  )

  llm = ComposerLLMConfig(integration_name="openai", provider_id="openai", model_id="gpt-4o")

  # Start a turn, then watch for a question.
  start = await client.assistant.chat("Send the welcome email to new signups", llm=llm)
  chat_id, run_id = start.chat_id, start.run_id

  while True:
      paused = None
      async with client.assistant.listen(chat_id, run_id) as stream:
          async for event in stream:
              if event.event == "user_input_request":
                  paused = user_input_request_from_event(event.data)  # nested data.data
                  break  # stop reading; go answer it
              if event.is_terminal:
                  break
      if paused is None:
          break  # the turn finished

      # Answer the question. Resume mints a NEW run_id.
      resumed = await client.assistant.resume(
          chat_id,
          request_id=paused.request_id,
          response=YesNoResponse(answer=True),
          llm=llm,  # required on assistant resume (no default)
      )
      run_id = resumed.run_id  # re-listen on the new run
  ```

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

  const client = new Modulex({
    apiKey: "mx_live_your_api_key",
    organizationId: "org_your_organization_id",
  });

  const start = await client.assistant.chat({
    message: "Send the welcome email to new signups",
    llm: { integration_name: "openai", provider_id: "openai", model_id: "gpt-4o" },
  });

  let chatId = start.chat_id;
  let runId = start.run_id;

  for (;;) {
    let paused = null;
    for await (const evt of client.assistant.listen(chatId, runId)) {
      if (evt.type === "user_input_request") {
        paused = evt.data; // UserInputRequest; discriminate on paused.kind
        break; // stop reading; go answer it
      }
      if (evt.type === "done" || evt.type === "error") break;
    }
    if (!paused) break; // the turn finished

    // Answer the question. Resume mints a NEW run_id.
    const resumed = await client.assistant.resume(chatId, {
      requestId: paused.request_id,
      response: { kind: "yes_no", answer: true },
      llm: { integration_name: "openai", provider_id: "openai", model_id: "gpt-4o" },
    });
    runId = resumed.run_id; // re-listen on the new run
  }
  ```
</CodeGroup>

<Note>
  `assistant.resume` requires `llm` (no default) in the Python and JavaScript SDKs, matching the endpoint's `400`-on-missing behavior. This differs from `composer.resume`, where `llm` defaults to `None`. See [streaming and HITL](/sdks/streaming-hitl).
</Note>

### OAuth auto-resume

When a `credential_request` offers an `oauth2` option and the user completes the OAuth flow, the chat resumes **without** a manual `resume` call. The OAuth callback re-runs the same three-layer guard, publishes a `run_resumed` event on the **old** run's channel (so a live client swaps to the new stream), and continues the turn on a new `run_id` — funneling through the same resume path. This is why Assistant chats are stored alongside Composer chats: the shared storage keeps OAuth auto-resume working.

```text run_resumed on the OLD run's channel theme={null}
data: {"type":"run_resumed","data":{"new_run_id":"b2c3d4e5-..."}}
```

When you see `run_resumed`, stop reading the current stream and open `/assistant/chat/{chat_id}/listen/{new_run_id}`.

### Resume errors

<ResponseField name="400" type="error">
  `llm` is missing from the resume body. Envelope: `{"detail": "..."}`.
</ResponseField>

<ResponseField name="403" type="error">
  The caller is not the user who triggered the question. Envelope: `{"detail": "..."}`. See [roles and permissions](/security/roles-permissions).
</ResponseField>

<ResponseField name="404" type="error">
  The chat does not exist or is not in your organization. Ownership failures return `404` (not `403`) so existence is never leaked. Envelope: `{"detail": "..."}`.
</ResponseField>

<ResponseField name="410" type="error">
  The question was already answered, cancelled, or expired, or your resume lost the atomic compare-and-delete race. Envelope: `{"detail": "..."}`. There is no dedicated `410` exception class in the SDKs — it surfaces as the base error type.
</ResponseField>

<ResponseField name="422" type="error">
  The `response` failed discriminated-union validation (for example a missing required field for its `kind`). Standard FastAPI validation envelope.
</ResponseField>

<ResponseField name="402 / 403 / 429" type="error">
  A billing or rate-limit denial on the continued turn. Flat `DenialEnvelope` shape — see [credit impact](#credit-impact). Distinct from the `403` ownership error above, which uses the `{"detail": "..."}` shape.
</ResponseField>

<Note>
  For the full pause/resume reference shared with the workflow-run variant — including how the [interrupt node](/workflow-builder/nodes/interrupt) resumes via the workflow thread (reusing the same `run_id`) rather than minting a new one — see [human-in-the-loop (HITL) resume](/realtime/hitl). The two flows are not interchangeable.
</Note>

## Paused runs

A paused Assistant run is a turn suspended on an open HITL question. Understanding its server-side state explains the concurrency rules and the expiry.

<AccordionGroup>
  <Accordion title="One pending question per chat">
    A chat may hold only **one** open question at a time. While a question is pending — or while a turn is still running — `POST /assistant/chat` returns `409`. The Assistant forces one tool call per model step, so two questions cannot fire in the same super-step. Answer or cancel the current question before starting a new turn.
  </Accordion>

  <Accordion title="The pending sentinel (7-day TTL)">
    Each open question sets a server-side pending sentinel keyed to the chat, with a **7-day** TTL. This is the authoritative window in which the question can be answered; the question's `timeout_hint_seconds` is only a UI hint. If the sentinel expires, a later resume returns `410`.
  </Accordion>

  <Accordion title="The run status">
    A paused run reports status `interrupted` in the server-side run-status doc (`{running, completed, failed, interrupted, cancelled}`, 1-hour TTL). `chat.running_id` stays set so the chat→run binding is preserved. The status document is what `GET /assistant/chat/{chat_id}/status` reads to report `awaiting_input` and the `pending_request_id`.
  </Accordion>

  <Accordion title="Rehydrating a pending question">
    `GET /assistant/chat/{chat_id}` re-surfaces a pending question on refresh: when `running_id` is set, the pending sentinel exists, and the matching audit row's `outcome` is still `pending`, the response includes `pending_user_input_request` with the full question payload. Otherwise it is `null`. This lets a client that disconnected pick the question back up without replaying the stream.
  </Accordion>

  <Accordion title="Checking status without the stream">
    `GET /assistant/chat/{chat_id}/status` returns `awaiting_input`, `pending_request_id`, `is_running`, `running_id`, and the raw `run_status` doc. `is_running` is `true` only when a run is active **and not** awaiting input — a paused run reports `is_running: false, awaiting_input: true`.
  </Accordion>
</AccordionGroup>

### Cancelling a paused run

`POST /assistant/chat/{chat_id}/cancel` cancels the active execution. If the run is paused on a HITL question, cancel also clears the pending sentinel and flips the audit row to `cancelled`, so `GET /assistant/chat` stops re-presenting the question. With no active run, cancel returns `400` (`"No active execution to cancel"`).

```text Status of a paused, then cancelled, run theme={null}
GET  /assistant/chat/{id}/status  →  {"is_running": false, "awaiting_input": true,  "pending_request_id": "yesno-req-42", ...}
POST /assistant/chat/{id}/cancel  →  {"status": "cancelled", "chat_id": "...", "run_id": "..."}
GET  /assistant/chat/{id}/status  →  {"is_running": false, "awaiting_input": false, "pending_request_id": null, ...}
```

<MediaEmbed id="MX-MEDIA-3250" type="app_video" caption={"The Assistant pausing mid-task on an approval card, the user answering, and the same conversation resuming on a new run."} />

## Credit impact

HITL does not change how a turn is metered — it shifts where the work happens.

<CardGroup cols={2}>
  <Card title="One run credit per turn" icon="coins" href="/billing/credits">
    A turn is charged **exactly one run credit** when it starts at `POST /assistant/chat`. A resume re-enters the conversation through `POST .../resume`, not `/chat`, so answering a question does **not** charge another run credit for the same turn.
  </Card>

  <Card title="Token usage is metered separately" icon="calculator" href="/billing/usage-gating">
    Language-model token usage is recorded on top of the run credit, on token counts, regardless of whether the turn succeeded, paused, or was cancelled. A paused turn that you resume keeps accumulating tokens on the continued run.
  </Card>
</CardGroup>

Both `POST /assistant/chat` and the resumed turn pass the synchronous [billing admission gate](/billing/usage-gating) before any work runs. On denial, the request is rejected before any rows are written and returns the flat `DenialEnvelope`:

```json DenialEnvelope (402 / 403 / 429) theme={null}
{ "code": "credit_plan_exhausted", "layer": "credit", "key": null, "current": null, "limit": null, "reason": "credit_plan_exhausted" }
```

The `layer` maps to the status code: `credit` and `wallet` return `402`, `quota` returns `403`, and `rate` returns `429` (with `Retry-After` and `X-RateLimit-*` headers). This is distinct from the standard `{"detail": "..."}` envelope used by validation and ownership errors — see [errors and status codes](/api-reference/errors) and [rate limiting](/api-reference/rate-limiting).

<Warning>
  The flat `DenialEnvelope` is emitted **only** on the metered surfaces — run, composer, **assistant**, and managed knowledge. Plain CRUD and org-settings routes return the `{"detail": "..."}` `HTTPException` shape instead. Your error handling on Assistant resume must account for both shapes.
</Warning>

## Reference: every HITL error

<table>
  <thead>
    <tr>
      <th>Status</th>
      <th>When</th>
      <th>Envelope</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>`400`</td>
      <td>`resume` body missing `llm`; or `cancel` with no active run</td>
      <td>`{"detail": "..."}`</td>
    </tr>

    <tr>
      <td>`403`</td>
      <td>Caller is not the question's originator; or not owner/admin</td>
      <td>`{"detail": "..."}`</td>
    </tr>

    <tr>
      <td>`404`</td>
      <td>Chat not found or not in your organization (no existence leak)</td>
      <td>`{"detail": "..."}`</td>
    </tr>

    <tr>
      <td>`409`</td>
      <td>`POST /assistant/chat` while a question is pending or a run is in progress</td>
      <td>`{"detail": "..."}`</td>
    </tr>

    <tr>
      <td>`410`</td>
      <td>`resume`: question already answered, cancelled, expired, or lost the atomic claim</td>
      <td>`{"detail": "..."}`</td>
    </tr>

    <tr>
      <td>`422`</td>
      <td>`resume`: `response` fails discriminated-union validation</td>
      <td>FastAPI validation error</td>
    </tr>

    <tr>
      <td>`402` / `403` / `429`</td>
      <td>Billing or rate-limit denial on the turn or resume</td>
      <td>`DenialEnvelope` `{code, layer, ...}`</td>
    </tr>
  </tbody>
</table>

## Where to go next

<CardGroup cols={2}>
  <Card title="HITL resume reference" icon="rotate-ccw" href="/realtime/hitl">
    The full pause/resume reference, including the workflow-run variant and the shared question/answer wire contract.
  </Card>

  <Card title="Interrupt node (HITL)" icon="circle-pause" href="/workflow-builder/nodes/interrupt">
    The workflow node that pauses a run to ask a human a structured question — the builder counterpart to Assistant HITL.
  </Card>

  <Card title="Streaming responses" icon="radio" href="/assistant/streaming">
    How to consume the Assistant's SSE stream, including the `user_input_request` and `run_resumed` frames.
  </Card>

  <Card title="Using tools" icon="wrench" href="/assistant/using-tools">
    How the Assistant discovers tools and requests credentials, the source of `credential_request` questions.
  </Card>

  <Card title="Permissions and limits" icon="lock" href="/assistant/permissions-and-limits">
    Who can use the Assistant and approve actions, and the usage limits that apply.
  </Card>

  <Card title="Streaming and HITL in the SDKs" icon="code" href="/sdks/streaming-hitl">
    The JavaScript and Python `resume` methods, the nested-payload helper, and the new-run\_id contract.
  </Card>
</CardGroup>
