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

# Interrupt node — pause a workflow for human approval (HITL)

> Configure the ModuleX workflow Interrupt node (InterruptNodeConfig): the message template, resume_schema, and examples fields; how the run pauses with a structured interrupt event and resumes on the same run_id via POST /workflows/resume; and why it has no streaming branch.

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

The **Interrupt node** (`type: "interrupt"`) pauses a running workflow to ask a person for input or approval, then resumes from exactly where it stopped once that person answers. It is the workflow engine's human-in-the-loop (HITL) primitive: instead of automating a decision, you hand it to a human and wait for a structured value to come back.

Use it whenever a run must not proceed without a human in the loop — approving a refund before it posts, choosing between drafted responses, supplying a value the workflow cannot compute, or gating a destructive [tool](/workflow-builder/nodes/tool) call. The node is one of the nine [node types](/workflow-builder/nodes/overview) the [workflow engine](/concepts/workflow-engine) compiles, and it is backed by the engine's checkpointer: a paused run is durably persisted in managed storage, so it can sit interrupted for as long as you need and survive a process restart.

<Note>
  The Interrupt node is the **workflow-engine** pause primitive. It is distinct from the chat HITL used by the [AI Composer](/concepts/ai-composer) and the [Assistant](/concepts/assistant), which pause with a `user_input_request` event and mint a **new** `run_id` on resume. The Interrupt node pauses with an `interrupt` event and resumes on the **same** `run_id`. Both contracts are mapped side by side in [Human-in-the-loop (HITL) resume](/realtime/hitl).
</Note>

## How the pause works

When a run reaches an enabled Interrupt node, the engine:

<Steps>
  <Step title="Resolves the message">
    Any `{{node_id.field}}` [references](/workflow-builder/variables-and-references) in `message` are resolved against the current run state, so the prompt the human sees can quote earlier results.
  </Step>

  <Step title="Builds the interrupt value">
    The node assembles a structured value `{message, data}` and adds `resume_schema` and `examples` if you configured them. `data` carries any legacy `data_keys` snapshot (see [Deprecated fields](#deprecated-fields)).
  </Step>

  <Step title="Calls interrupt() and pauses">
    The node raises an interrupt. Execution stops at this node, the run state is checkpointed to managed storage, and the run status is set to `interrupted`.
  </Step>

  <Step title="Emits the interrupt event">
    The executor publishes a single `interrupt` event on the run's [SSE stream](/realtime/sse-streaming) carrying the resolved message, data, schema, and examples. The stream then goes quiet — there is no terminal frame (see [The interrupt event](#the-interrupt-event)).
  </Step>

  <Step title="Waits for resume">
    The run waits indefinitely until you call the resume endpoint. On resume, the value you send is stored in run state under the node's `id`, and execution continues to the next node.
  </Step>
</Steps>

<MediaEmbed id="MX-MEDIA-3130" type="image" caption={"Lifecycle diagram of an Interrupt node pausing and resuming a run."} />

## Inputs and outputs

**Input.** The Interrupt node reads run state to resolve `{{...}}` references inside `message` (and inside any legacy `data_keys`). It has no `input_mapping` of its own.

**Output.** When the run resumes, the **resume value you supply is written to run state under the node's `id`**, following the engine-wide convention that every node writes its result under its own id. Downstream nodes read it with a reference such as `{{node_approval.approved}}`.

<Warning>
  There is no `output_key`. The result is always stored under the node `id`. `output_key` exists on the config only as a deprecated no-op (see [Deprecated fields](#deprecated-fields)).
</Warning>

<ResponseField name="<node_id>" type="any">
  The resume value passed to `POST /workflows/resume`. If you send a JSON object as `resume_value`, the whole object is stored under the node id — for example `{"approved": true, "notes": "Approved by manager"}` is read downstream as `{{node_approval.approved}}` and `{{node_approval.notes}}`. A scalar resume value is stored as-is.
</ResponseField>

## Configuration

The node carries an `interrupt_config` object of type `InterruptNodeConfig`. In the [builder](/workflow-builder/overview), these fields are edited in the node's detail panel; over the API they live under the node definition.

<ParamField path="message" type="string" required>
  The prompt shown to the human. Supports `{{node_id.field}}` [references](/workflow-builder/variables-and-references), so you can quote upstream results — for example `Approve sending {{node_draft.subject}} to {{node_lookup.email}}?`. References are resolved at pause time against the current run state; any reference that cannot be resolved is left intact in the string.
</ParamField>

<ParamField path="resume_schema" type="object">
  An optional JSON Schema describing the shape of the value the human is expected to return. It is forwarded verbatim in the `interrupt` event so the UI or SDK consumer can render and validate the answer form. **The engine does not enforce this schema on the resume value** — it is advisory metadata for the client, not a server-side validator. To hard-validate the returned value, add a downstream [Guardrails node](/workflow-builder/nodes/guardrails) or [Conditional node](/workflow-builder/nodes/conditional).
</ParamField>

<ParamField path="examples" type="object">
  Optional example resume values, forwarded verbatim in the `interrupt` event to help a client render choices or defaults — for example `{"approve": {"approved": true}, "reject": {"approved": false}}`. Advisory only; not validated by the engine.
</ParamField>

<ParamField path="data_keys" type="array" deprecated>
  **Deprecated.** A list of state keys to snapshot into the event's `data` object. Use `{{node_id}}` references inside `message` instead. When set, the engine copies each listed key from run state into `data`; when empty (the default), `data` is `{}`.
</ParamField>

<ParamField path="output_key" type="string" deprecated>
  **Deprecated no-op.** The output is always stored under the node `id`; this field is ignored.
</ParamField>

### Deprecated fields

`data_keys` and `output_key` predate the `{{node_id.field}}` reference system. Do not author new workflows with them:

* Replace `data_keys` by inlining the values you want the human to see directly in `message` with references, e.g. `Refund {{node_order.amount}} for order {{node_order.id}}?`.
* Ignore `output_key` entirely; the resume value always lands under the node `id`.

<MediaEmbed id="MX-MEDIA-3131" type="screenshot" caption={"The Interrupt node detail panel in the workflow builder."} />

## The interrupt event

While paused, the run is observable on `GET /workflows/listen/{run_id}` — the standard run [SSE stream](/realtime/sse-streaming). The Interrupt node produces one `interrupt` frame, a **wrapped** event whose `data` object carries the resolved message and the forwarded schema and examples:

```text Interrupt frame on the run SSE stream theme={null}
data: {"type": "interrupt", "data": {"thread_id": "chat_9", "message": "Approve this action?", "data": {}, "resume_schema": {"type": "object", "properties": {"approved": {"type": "boolean"}}}, "examples": {"approve": {"approved": true}, "reject": {"approved": false}}}}
```

Important properties of this event, all verified against the executor:

* **`interrupt` is not a terminal event.** The stream does not close after it; it goes quiet and idles on `heartbeat` frames. The run status is `interrupted`, persisted as a terminal run row, but the live SSE stream has no `done`/`error`/`cancelled` frame at the pause.
* **`interrupted` is a history-only marker.** A separate `interrupted` type is appended to the run's event history (used to stop history replay on reconnect); it is **never published live**. A client watching live sees the `interrupt` event, not an `interrupted` one.
* **The discriminator is the JSON `type` field.** The run stream is data-only — frames are `data: <json>\n\n` with no SSE `event:` line. Parse `data` and switch on `.type`.

For the full run-event taxonomy and framing, see [SSE run streaming](/realtime/sse-streaming).

## Resuming the run

You resume a paused run by sending the human's answer to the resume endpoint. The run continues on the **same `run_id`** from the checkpoint where it stopped.

<ParamField path="resume_value" type="any" required>
  The value to inject as the interrupt result. Stored under the Interrupt node's `id`. Send a JSON object to expose multiple fields downstream.
</ParamField>

<ParamField path="run_id" type="string" required>
  The `run_id` of the interrupted run. The endpoint returns `400` if it is missing and binds it to the resumed thread.
</ParamField>

<ParamField path="workflow_id" type="string">
  The deployed workflow to load the schema from (database mode). Provide this **or** an inline `workflow` definition (ad-hoc mode).
</ParamField>

<ParamField path="workflow" type="object">
  An inline workflow definition (ad-hoc mode). Mutually exclusive with `workflow_id`.
</ParamField>

The `thread_id` is the checkpoint identifier and is passed in the URL path (`POST /workflows/resume/{thread_id}`). On resume, the database default `input` and `config` are **ignored** — the run continues from the persisted checkpoint state, and `resume_value` is the interrupt answer, not new input.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.modulex.dev/workflows/resume/chat_9" \
    -H "Authorization: Bearer mx_live_xxxxxxxxxxxxxxxxxxxx" \
    -H "X-Organization-ID: org_7Hk2a9QpZ1" \
    -H "Content-Type: application/json" \
    -d '{
      "run_id": "6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
      "workflow_id": "550e8400-e29b-41d4-a716-446655440000",
      "resume_value": { "approved": true, "notes": "Approved by manager" }
    }'
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import Modulex

  client = Modulex(
      api_key="mx_live_xxxxxxxxxxxxxxxxxxxx",
      organization_id="org_7Hk2a9QpZ1",
  )

  async def main():
      await client.executions.resume(
          "chat_9",  # thread_id (the checkpoint)
          run_id="6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
          resume_value={"approved": True, "notes": "Approved by manager"},
          workflow_id="550e8400-e29b-41d4-a716-446655440000",
      )

  asyncio.run(main())
  ```

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

  const client = new Modulex({
    apiKey: "mx_live_xxxxxxxxxxxxxxxxxxxx",
    organizationId: "org_7Hk2a9QpZ1",
  });

  await client.executions.resume({
    threadId: "chat_9", // the checkpoint
    runId: "6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
    resumeValue: { approved: true, notes: "Approved by manager" },
    workflowId: "550e8400-e29b-41d4-a716-446655440000",
  });
  ```
</CodeGroup>

The moment the run resumes, the executor publishes a `resumed` event on the same `run_id` stream so any listener observes the continuation, then normal `node_update` frames follow as downstream nodes execute:

```text Resume continuation on the same run_id stream theme={null}
data: {"type": "resumed", "data": {"run_id": "6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d", "thread_id": "chat_9", "resume_value": {"approved": true, "notes": "Approved by manager"}, "timestamp": "2026-06-21T10:06:00Z"}}

data: {"type": "node_update", "node": "node_after", "output": {"node_after": "posted"}}

data: {"type": "done", "data": {"message": "Workflow completed successfully"}}
```

For the SDK consumption patterns around answering an interrupt while streaming, see [Streaming & HITL](/sdks/streaming-hitl).

## No streaming branch

The Interrupt node has **no token-streaming branch**. Token-level streaming exists only on the [LLM node](/workflow-builder/nodes/llm) in its simple-LLM/messages-state mode; the Interrupt node neither calls a model nor emits partial output. Its only stream emission is the single, complete `interrupt` event when it pauses and the `resumed` event when it continues. There is nothing to stream incrementally — the node's job is to stop and wait.

## Retry behavior

The Interrupt node is the **one node type the engine does not retry-wrap**. A pause is not a failure, so it would make no sense to retry it, and `retry_config` has no effect on an Interrupt node. By contrast, every other node type ([LLM](/workflow-builder/nodes/llm), [tool](/workflow-builder/nodes/tool), [agent](/workflow-builder/nodes/agent), [function](/workflow-builder/nodes/function), [conditional](/workflow-builder/nodes/conditional), [transformer](/workflow-builder/nodes/transformer), [guardrails](/workflow-builder/nodes/guardrails), [knowledge](/workflow-builder/nodes/knowledge)) is wrapped with retry-and-events handling. See [Error handling & retries](/workflow-builder/error-handling-retries).

## Credit impact

The Interrupt node itself **consumes no credits**. The workflow engine has no fixed per-node charge; [credits](/billing/credits) are metered only by the operations that incur cost — LLM and agent token usage, and managed knowledge retrieval. Pausing and resuming are free. A run can stay interrupted indefinitely at no credit cost.

Resuming a run is **not** billing-gated. The resume route (`POST /workflows/resume/{thread_id}`) does not call the admission gate; that gate is live only on the run, composer, assistant, and managed-knowledge surfaces. A resume continues from the checkpoint regardless of your organization's credit or rate-limit state.

## Errors

<ResponseField name="400 Bad request" type="HTTPException">
  The resume request is missing `resume_value` or `run_id`. Shape: `{detail: "..."}`. Resume also returns `400` if the run is not actually in an `interrupted`/`running` state.
</ResponseField>

<ResponseField name="401 / 403 Auth" type="HTTPException">
  The resume endpoint requires an organization **owner or admin** (the `member` role is retired). A missing or invalid `Authorization: Bearer` token returns `401`; an authenticated non-owner/admin returns `403`. Shape: `{detail: "..."}`. See [Roles & permissions](/security/roles-permissions).
</ResponseField>

<ResponseField name="404 Run not found" type="HTTPException">
  The `thread_id`/`run_id` does not exist **or** is not owned by your organization. Ownership failures return an identical `404` (not `403`) by design, so there is no existence leak. Shape: `{detail: "..."}`.
</ResponseField>

<ResponseField name="500 Internal error" type="HTTPException">
  An unexpected failure while loading the checkpoint or resuming the run. Shape: `{detail: "..."}`.
</ResponseField>

<ResponseField name="ValueError — missing interrupt_config" type="build-time">
  A node of `type: "interrupt"` without an `interrupt_config` fails workflow build/validation before the run starts. The `message` may be empty — `InterruptNodeConfig.message` has no minimum length — but the `interrupt_config` object itself must be present.
</ResponseField>

In-run failures elsewhere in the workflow surface as `node_error` frames carrying a stable `reason` code; the Interrupt node does not raise during the pause itself — it simply waits.

## Worked example: approve before sending

A three-node flow drafts an email with an [LLM node](/workflow-builder/nodes/llm), pauses for a human to approve, and only sends on approval. The [Conditional node](/workflow-builder/nodes/conditional) after the interrupt branches on the approval the human returned.

<Expandable title="Workflow definition (excerpt)">
  ```json Interrupt node in a WorkflowDefinition theme={null}
  {
    "nodes": [
      {
        "id": "node_draft",
        "type": "llm",
        "x": 0,
        "y": 0,
        "llm_config": {
          "llm": { "integration_name": "modulexai", "model_id": "claude-haiku-4.5" },
          "system_prompt": "Draft a short customer email.",
          "user_prompt": "Write a reply to: {{input.request}}"
        }
      },
      {
        "id": "node_approval",
        "type": "interrupt",
        "x": 320,
        "y": 0,
        "interrupt_config": {
          "message": "Approve sending this draft?\n\n{{node_draft}}",
          "resume_schema": {
            "type": "object",
            "properties": {
              "approved": { "type": "boolean" },
              "notes": { "type": "string" }
            },
            "required": ["approved"]
          },
          "examples": {
            "approve": { "approved": true },
            "reject": { "approved": false, "notes": "Tone too casual" }
          }
        }
      },
      {
        "id": "node_route",
        "type": "conditional",
        "x": 640,
        "y": 0,
        "conditional_config": {
          "condition_type": "expression",
          "expression_branches": [
            {
              "id": "b_send",
              "source": "{{node_approval.approved}}",
              "operator": "equals",
              "value": true,
              "target": "node_send"
            }
          ],
          "default_target": "node_end_rejected"
        }
      }
    ],
    "edges": [
      { "source": "node_draft", "target": "node_approval" },
      { "source": "node_approval", "target": "node_route" }
    ]
  }
  ```
</Expandable>

When the run reaches `node_approval`, it pauses and emits the `interrupt` event with the resolved message (the draft inlined via `{{node_draft}}` — the LLM node writes its text under its own id, so `{{node_draft}}` resolves to the draft string), the `resume_schema`, and the `examples`. A reviewer answers by calling `POST /workflows/resume/{thread_id}` with `resume_value: {"approved": true}`. The value lands under `node_approval`, the Conditional node reads `{{node_approval.approved}}`, and the run routes to `node_send`. Had the reviewer sent `{"approved": false, "notes": "..."}`, the Conditional node's `default_target` would route to the rejection branch instead.

## Related

<CardGroup cols={2}>
  <Card title="Human-in-the-loop (HITL) resume" icon="hand" href="/realtime/hitl">
    The two pause/resume contracts compared: the workflow Interrupt node vs the chat HITL `user_input_request`.
  </Card>

  <Card title="SSE run streaming" icon="signal-stream" href="/realtime/sse-streaming">
    The run event stream that carries the `interrupt` and `resumed` events.
  </Card>

  <Card title="Variables & references" icon="brackets-curly" href="/workflow-builder/variables-and-references">
    The `{{node_id.field}}` reference system used in the message and read downstream.
  </Card>

  <Card title="Node types overview" icon="diagram-project" href="/workflow-builder/nodes/overview">
    All nine node types and how each writes its result into run state.
  </Card>
</CardGroup>
