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

# Workflow engine & nodes

> How the ModuleX workflow engine compiles a JSON workflow into a runnable graph: the 9 node types, edges, the __start__/__end__ virtual nodes, run state and reducers, and the {{nodeId.path}} reference model.

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 workflow engine is the runtime that turns a `WorkflowDefinition` — the JSON contract you build in the [workflow builder](/workflow-builder/overview) or generate with the [AI Composer](/concepts/ai-composer) — into an executable graph and runs it. This page documents the engine's contract: how a workflow compiles, the 9 node types and what each writes to state, how edges and the virtual `__start__`/`__end__` endpoints work, how run state and reducers behave, and how the `{{nodeId.path}}` reference syntax resolves data between steps.

This is the conceptual model behind every run. For the per-node configuration reference, see the individual [node type pages](/workflow-builder/nodes/overview). For the wire-level run lifecycle and events, see [SSE run streaming](/realtime/sse-streaming).

<MediaEmbed id="MX-MEDIA-1100" type="image" caption={"Diagram of how a JSON workflow definition compiles into a runnable graph."} />

## The mental model

A workflow is a directed graph of **nodes** connected by **edges**, executed over a shared **run state**. The engine evaluates the graph one node at a time (or in parallel where a loop fans out), and every node writes its result back into state. Later nodes read earlier results through the `{{nodeId.path}}` reference syntax.

Four pieces make up the contract:

<CardGroup cols={2}>
  <Card title="Nodes" icon="box">
    The 9 step types — `llm`, `tool`, `agent`, `function`, `conditional`, `interrupt`, `transformer`, `guardrails`, `knowledge`. Each node writes its result to run state under its own `id`.
  </Card>

  <Card title="Edges" icon="arrow-right">
    Directed connections between nodes. The virtual `__start__` and `__end__` endpoints mark where execution enters and leaves the graph.
  </Card>

  <Card title="State" icon="database">
    A dynamic dictionary that flows through the run. Its fields come from your `state_schema`, plus one auto-added field per node id, plus any loop fields.
  </Card>

  <Card title="References" icon="braces">
    The `{{nodeId.path}}` syntax that pulls a value out of state — typed when the whole field is a single reference, templated when embedded in a string.
  </Card>
</CardGroup>

Under the hood, the engine compiles your `WorkflowDefinition` into an executable state graph, runs it in a background task, persists run state through the managed checkpointer, and streams node and run events over [SSE](/realtime/sse-streaming). You do not manage the engine directly — you describe the graph in JSON and the engine handles compilation, persistence, and streaming.

## The `WorkflowDefinition`

A workflow is a single JSON object. These are its top-level fields.

<ResponseField name="metadata" type="object">
  Workflow name, description, version, and tags. The engine denormalizes some of these for fast filtering, but the workflow definition is the source of truth.
</ResponseField>

<ResponseField name="config" type="object">
  Execution configuration. Several fields are accepted on the wire but **forced to constant values** by the engine regardless of what you send (see [Execution config](#execution-config) below).
</ResponseField>

<ResponseField name="state_schema" type="object">
  Declares the named fields that flow through the run, their types, and their reducers. See [Run state](#run-state).
</ResponseField>

<ResponseField name="nodes" type="array" required>
  The list of `NodeDefinition` objects. **The virtual `__start__` and `__end__` endpoints must not appear here** — they are wired automatically and a validator rejects a definition that lists them as nodes.
</ResponseField>

<ResponseField name="edges" type="array" required>
  The list of `EdgeDefinition` objects connecting nodes. Edges may also express conditional branches and loops.
</ResponseField>

<ResponseField name="entry_point" type="string" default="__start__">
  The node where execution begins. Defaults to the virtual `__start__` endpoint.
</ResponseField>

<ResponseField name="start_position" type="object">
  The canvas `{x, y}` of the `__start__` endpoint. Visual only — it has no effect on execution.
</ResponseField>

<Note>
  The definition is parsed with unknown top-level keys ignored rather than causing an error, so build against the fields documented here.
</Note>

### Execution config

`config` accepts the fields below, but the engine **overrides** most of them with fixed values. You can send them, but only `recursion_limit` is honored from your input.

| Field                       | Wire default | Effective value    | Notes                                                                                                                                                        |
| --------------------------- | ------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `recursion_limit`           | `500`        | respected          | The maximum number of node transitions in one run. The default of 500 supports loops up to roughly 100 iterations (each node transition counts as one step). |
| `enable_checkpointing`      | `true`       | always `true`      | State is always persisted; you cannot disable it.                                                                                                            |
| `checkpointer_type`         | `managed`    | always managed     | The checkpointer is always backed by managed storage. See [Checkpointer and threads](#checkpointer-and-threads).                                             |
| `stream_mode`               | `"updates"`  | always `"updates"` | The engine streams per-node updates as they complete.                                                                                                        |
| `enable_subgraph_streaming` | `true`       | always `true`      | Subgraph events stream through to the client.                                                                                                                |

A run can override `recursion_limit` at execution time through its `ExecutionConfig` (`thread_id?`, `recursion_limit?`, `checkpoint_id?`); otherwise the workflow's configured limit applies.

## Nodes

A workflow has exactly **9 node types**. Every node, whatever its type, follows the same output convention: **it writes its result to run state under its own `id`**. A node with `id: "node_llm_1"` produces `state["node_llm_1"]`, and later nodes reference it as `{{node_llm_1}}`.

<Note>
  Several configs expose a legacy `output_key` field. It is **deprecated** — node output always lands under the node `id`, not a custom key. Do not rely on `output_key`, `input_keys`, or `prompt_template`; they are kept only for backward compatibility.
</Note>

Each node type maps to one configuration object on the `NodeDefinition`. The full per-node parameter reference lives on the dedicated node pages; the table below is the index.

| Type          | Config slot          | What it does                                                                 | Reference                                               |
| ------------- | -------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------- |
| `llm`         | `llm_config`         | Calls a language model with system/user prompts, optional structured output. | [LLM node](/workflow-builder/nodes/llm)                 |
| `tool`        | `tool_config`        | Calls a single integration tool (action).                                    | [Tool node](/workflow-builder/nodes/tool)               |
| `agent`       | `agent_config`       | Runs an autonomous tool-calling loop up to `max_iterations`.                 | [Agent node](/workflow-builder/nodes/agent)             |
| `function`    | `function_config`    | Runs a built-in registry function (HTTP, webhook, schema validation).        | [Function node](/workflow-builder/nodes/function)       |
| `conditional` | `conditional_config` | Branches on an expression or LLM decision, or controls a loop.               | [Conditional node](/workflow-builder/nodes/conditional) |
| `interrupt`   | `interrupt_config`   | Pauses the run to ask a human a structured question.                         | [Interrupt node](/workflow-builder/nodes/interrupt)     |
| `transformer` | `transformer_config` | Reshapes and combines data between steps.                                    | [Transformer node](/workflow-builder/nodes/transformer) |
| `guardrails`  | `guardrails_config`  | Validates content with JSON/regex/PII checks.                                | [Guardrails node](/workflow-builder/nodes/guardrails)   |
| `knowledge`   | `knowledge_config`   | Retrieves from a knowledge base.                                             | [Knowledge node](/workflow-builder/nodes/knowledge)     |

An unknown `type` fails compilation with an "Unsupported node type" error. See the [node types overview](/workflow-builder/nodes/overview) for the complete list and how each writes to state.

### The `NodeDefinition`

Every node, regardless of type, shares this shape.

<ParamField path="id" type="string" required>
  The node's unique identifier within the workflow. This is the key its output is written under in run state, and the name later nodes reference with `{{id}}`.
</ParamField>

<ParamField path="type" type="string" required>
  One of the 9 node types.
</ParamField>

<ParamField path="name" type="string">
  A human-readable label shown in the builder.
</ParamField>

<ParamField path="description" type="string">
  An optional description of what the node does.
</ParamField>

<ParamField path="enabled" type="boolean" default="true">
  Whether the node participates in the run. A disabled node is dropped at compile time and its edges are rewired to the next enabled target, so you can disable a step without rewiring the graph by hand. A `null` value is treated as `true`.
</ParamField>

<ParamField path="x" type="number" required>
  The node's horizontal canvas position. Visual only.
</ParamField>

<ParamField path="y" type="number" required>
  The node's vertical canvas position. Visual only.
</ParamField>

<ParamField path="retry_config" type="object">
  Optional per-node retry policy. Most node types are retry-wrapped; the `interrupt` node is **not** retry-wrapped, because retrying a human pause is meaningless.
</ParamField>

<ParamField path="<type>_config" type="object" required>
  Exactly one type-specific configuration slot, matching the node `type` (for example, `llm_config` for an `llm` node). The builder may send a wrapped `{"config": {...}}` form; the engine normalizes it to the type-specific key automatically.
</ParamField>

### What each node type writes to state

Because every node writes under its own `id`, knowing each type's output shape tells you exactly what later nodes can reference.

<AccordionGroup>
  <Accordion title="llm — language-model call" icon="message-square">
    Resolves the system and user prompt templates, calls the model, and returns the response text under the node id. If you configure a `structured_output_schema`, the engine asks the model for structured JSON and returns the parsed object instead, falling back to JSON extraction from the raw response if structured output fails. LLM usage is metered in [credits](/billing/credits) after each call. See the [LLM node](/workflow-builder/nodes/llm).
  </Accordion>

  <Accordion title="tool — single integration action" icon="wrench">
    Resolves the `input_mapping` (which uses `{{...}}` references), loads the tool for the configured integration and credential, invokes it, and returns the unwrapped result under the node id. A `{success, action, result: {...}}` wrapper is flattened to the root, with `result` kept for backward-compatible `.result.field` paths. The tool node honors both `parameter_defaults` and `parameter_overrides`. See the [Tool node](/workflow-builder/nodes/tool).
  </Accordion>

  <Accordion title="agent — autonomous tool loop" icon="bot">
    Binds the configured tools to the model and runs a tool-calling loop up to `max_iterations` (default 10): each turn invokes the model, executes any tool calls, and appends the results. The agent applies `parameter_defaults` only — `parameter_overrides` are **ignored** for agents. It raises if the resolved input is empty, and meters LLM usage every iteration. See the [Agent node](/workflow-builder/nodes/agent).
  </Accordion>

  <Accordion title="function — built-in registry function" icon="function-square">
    Looks up a built-in function by `function_name` and executes it. On success it writes `result.data` under the node id; on failure it writes `{error, success: false, ...}` so downstream nodes can branch on the error. There are exactly four built-ins: `http_request`, `send_webhook`, `validate_schema`, and `validate_workflow_schema`. See the [Function node](/workflow-builder/nodes/function).
  </Accordion>

  <Accordion title="conditional — branch or loop" icon="git-branch">
    Has a `condition_type` of `"expression"`, `"llm"`, or `"loop"`. An expression node evaluates ordered branches and stores the matched target; the engine then adds conditional edges that route on it. An LLM node asks the model to pick a route. A loop node acts as the loop controller. See the [Conditional node](/workflow-builder/nodes/conditional).
  </Accordion>

  <Accordion title="interrupt — human-in-the-loop pause" icon="hand">
    Builds a question payload (`message`, `data`, optional `resume_schema` and `examples`) and pauses the run. The active client receives an `interrupt` event; the run status becomes `interrupted`. On [resume](/realtime/hitl), the human's value is stored under the node id. This node is **not** retry-wrapped. See the [Interrupt node](/workflow-builder/nodes/interrupt).
  </Accordion>

  <Accordion title="transformer — reshape data" icon="shuffle">
    Resolves a `source` reference and applies an ordered list of operations (string, object, array, type, date, and math operations). The transformed value is written under the node id. See the [Transformer node](/workflow-builder/nodes/transformer).
  </Accordion>

  <Accordion title="guardrails — validate content" icon="shield-check">
    Runs JSON-schema, regex, and PII checks against a `source` reference, with an `on_failure` action of `block`, `warn`, `transform`, or `route`. The hallucination check is a placeholder and returns a "coming soon" marker. The result records which validations ran, whether the content was valid, and any findings. See the [Guardrails node](/workflow-builder/nodes/guardrails).
  </Accordion>

  <Accordion title="knowledge — retrieve from a knowledge base" icon="book-open">
    Resolves a `query` reference and retrieves from the configured [knowledge base](/concepts/knowledge-rag). Managed (`modulexdb`) retrieval reserves and records [credits](/billing/credits); bring-your-own-key vector stores are uncosted. The output is formatted as `chunks`, `context`, or `both`. See the [Knowledge node](/workflow-builder/nodes/knowledge).
  </Accordion>
</AccordionGroup>

## Edges and the virtual endpoints

Edges are directed connections between nodes. Two endpoint names are **virtual**: `__start__` and `__end__`. They mark where execution enters and leaves the graph but are not real nodes — they have no configuration and **must not appear in the `nodes` array**.

```json title="Edges with the virtual endpoints" theme={null}
{
  "edges": [
    { "source": "__start__", "target": "node_llm_1" },
    { "source": "node_llm_1", "target": "node_http_2" },
    { "source": "node_http_2", "target": "__end__" }
  ]
}
```

A few rules govern how edges compile:

<Steps>
  <Step title="__start__ wires to graph entry">
    An edge from `__start__` connects the engine's entry point to your first node. The workflow's `entry_point` defaults to `__start__`.
  </Step>

  <Step title="__end__ wires to graph exit">
    An edge to `__end__` terminates a branch.
  </Step>

  <Step title="Terminal nodes are auto-connected">
    Any enabled node that has no outgoing edge is automatically connected to the exit. **An explicit edge to `__end__` is therefore optional** — leave it out and the node still terminates correctly.
  </Step>

  <Step title="Disabled nodes are bypassed">
    A node with `enabled: false` is dropped at compile time and its edges are rewired to the next enabled target, recursively.
  </Step>

  <Step title="Fan-in is guarded">
    A node with more than one incoming edge is wrapped so it runs once after its predecessors converge, rather than once per incoming edge.
  </Step>
</Steps>

<Warning>
  Listing `__start__` or `__end__` in the `nodes` array is rejected by the workflow validator as a virtual-node error. They belong only in `edges` and in `entry_point`.
</Warning>

## Run state

State is a single dictionary that flows through the run. Its fields come from three sources, combined at compile time:

1. **Your declared fields** — the `state_schema.fields` you define.
2. **One field per node id** — the engine auto-adds a field (typed `Any`) for every node so its output streams correctly. This is why `{{node_id}}` always resolves to that node's output.
3. **Loop fields** — auto-added bookkeeping fields for any loop (see [Loops](#loops)).

### Declaring a state field

Each entry in `state_schema.fields` is a `StateField`.

<ParamField path="type" type="string" required>
  One of `string`, `integer`, `float`, `boolean`, `object`, `array`, or `messages` (mapped to Python `str`, `int`, `float`, `bool`, `dict`, `list`, and `list` respectively).
</ParamField>

<ParamField path="description" type="string">
  An optional description of the field.
</ParamField>

<ParamField path="reducer" type="string" default="none">
  How concurrent or repeated writes to this field merge. One of `none`, `add`, or `update` (see [Reducers](#reducers)).
</ParamField>

<ParamField path="required" type="boolean">
  Whether the field must be present.
</ParamField>

<ParamField path="default" type="any">
  The value applied at run start if no input overrides it.
</ParamField>

At run start, the engine applies declared `default`s first, then type-appropriate empty values for anything without a default, then your run `input` on top — so input always wins.

### Reducers

A reducer decides what happens when a field is written more than once (for example, by parallel branches of a loop).

<ResponseField name="none" type="reducer">
  Plain replace — the new value overwrites the old. This is the default.
</ResponseField>

<ResponseField name="add" type="reducer">
  A smart-append reducer. For `array` fields it appends, auto-wrapping a scalar into a single-element list (`[] + "x"` becomes `["x"]`); for other types it uses addition. Use this to accumulate results across loop iterations.
</ResponseField>

<ResponseField name="update" type="reducer">
  A type-safe dictionary merge. New keys are added and existing keys are overwritten, while the rest of the object is preserved.
</ResponseField>

<Note>
  The reducer matters most inside loops and parallel branches, where the same field is written multiple times. For a straight-line workflow, the default `none` (replace) is usually what you want. See [Variables & references](/workflow-builder/variables-and-references) for worked examples.
</Note>

## References — `{{nodeId.path}}`

References are how data moves between nodes. The syntax is `{{nodeId.path}}`, where `nodeId` is a node's `id` and `path` is an optional dot-and-bracket path into that node's output.

```text title="Reference forms" theme={null}
{{node_abc123}}                        whole output of node_abc123
{{node_search.results[0].title}}       nested field via dot + bracket path
{{node_http_2.body.status}}            field of a structured result
```

How a reference resolves depends on where it appears:

<Tabs>
  <Tab title="Whole-value reference">
    When a field is **exactly** one `{{...}}` reference, the engine returns the **native typed value** — a number stays a number, an object stays an object, an array stays an array. Use this to pass structured data between nodes without stringifying it.

    ```json title="Typed pass-through" theme={null}
    {
      "input_mapping": {
        "results": "{{node_search.results}}"
      }
    }
    ```
  </Tab>

  <Tab title="Templated string">
    When a `{{...}}` reference is **embedded in a larger string**, the engine substitutes it as text. Object and array values are JSON-encoded into the string. Unresolved references are left intact rather than blanked out.

    ```json title="String interpolation" theme={null}
    {
      "user_prompt": "Summarize this article: {{node_fetch.body}}"
    }
    ```
  </Tab>

  <Tab title="Array spread">
    A **list item** of the form `{{...nodeId.path}}` (note the leading `...`) is spliced into the surrounding list when it resolves to a list; a scalar or object is appended, and `None` contributes nothing. The `...` prefix only has spread meaning in a list-item position.

    ```json title="Spreading a list into a list" theme={null}
    {
      "items": ["{{...node_a.rows}}", "{{...node_b.rows}}", "literal"]
    }
    ```
  </Tab>
</Tabs>

Resolution rules to keep in mind:

* **Missing paths resolve to nothing.** An out-of-range index, a missing key, or a malformed bracket path resolves to `null` silently rather than raising — design downstream nodes to tolerate `null`.
* **`input_mapping` skips empty values.** Keys whose resolved value is `null` or an empty string are dropped from the mapping, so they never reach the tool or function.
* **Mixed and nested structures recurse.** A reference inside a nested object or list is resolved per-item, preserving the surrounding structure.

See [Variables & references](/workflow-builder/variables-and-references) for the full reference cookbook.

## Loops

A loop repeats a section of the graph. Loops are expressed either as an edge whose condition type is `loop`, or as a `conditional` node whose `condition_type` is `"loop"` (the node acts as the loop controller). A loop has three modes:

<ResponseField name="for" type="loop mode">
  A fixed number of iterations, from `iterations` or a `iterations_ref` reference.
</ResponseField>

<ResponseField name="foreach" type="loop mode">
  One iteration per item in a `collection` (a `{{...}}` reference). Set `parallel: true` to fan the iterations out concurrently; otherwise they run in sequence.
</ResponseField>

<ResponseField name="while" type="loop mode">
  Repeats while a condition expression is true, bounded by `max_iterations` (default 100) as a safety stop.
</ResponseField>

Every loop auto-creates bookkeeping fields in run state, keyed by its `loop_id`:

| Field                       | Type    | Present for             | Meaning                                                            |
| --------------------------- | ------- | ----------------------- | ------------------------------------------------------------------ |
| `{loop_id}_iteration`       | integer | all loops               | The current iteration count.                                       |
| `{loop_id}_initialized`     | boolean | all loops               | Whether the loop has started.                                      |
| `{loop_id}_completed`       | boolean | all loops               | Whether the loop has finished.                                     |
| `{loop_id}_item`            | any     | foreach                 | The current item.                                                  |
| `{loop_id}_index`           | integer | foreach                 | The current item's index.                                          |
| `{loop_id}_collection_size` | integer | foreach                 | The total number of items.                                         |
| `{loop_id}_results`         | array   | when `accumulate` is on | Accumulated per-iteration results (uses the smart-append reducer). |

A loop's `exit_target` defaults to `__end__` — an empty or missing exit target is normalized to the virtual end. Set `body_target` to the first node of the loop body, and optionally `body_end` (it defaults to `body_target`) to mark where one iteration ends.

<Note>
  `recursion_limit` (default 500) caps total node transitions across the whole run, including every loop iteration. A long-running `foreach` over a large collection can exhaust it — raise the limit on `config` or at run time, or set `parallel: true` to fan out instead of iterating.
</Note>

## Checkpointer and threads

Every run's state is persisted by the managed checkpointer, backed by the same managed datastore as the rest of ModuleX. Checkpointing is always on — the `enable_checkpointing` and `checkpointer_type` config fields are forced, so you cannot turn it off or switch to an in-memory saver.

The checkpointer is what makes [human-in-the-loop](/realtime/hitl) possible: when an `interrupt` node pauses a run, its full state is checkpointed, and a resume call restores that state and continues the **same** run from where it stopped.

A **thread** is the unit of checkpoint continuity, identified by a `thread_id` on the run config. When you run a workflow from a chat, the chat's id is reused as the thread id, so the run shares the conversation's checkpoint thread; an ephemeral run without a thread gets a generated id.

## Run lifecycle and events

A run executes in a background task: it persists a durable run row, streams a `metadata` event, applies state defaults, then steps the graph node by node, publishing a `node_update` event as each node completes. An `interrupt` pauses the run; `done`, `error`, or `cancelled` terminate it. A run has a default timeout of one hour.

You observe a run by listening to its [SSE stream](/realtime/sse-streaming). The wire is a sequence of `data: <json>\n\n` frames with **no `event:` line** — the event discriminator is the `type` key inside each JSON payload, so a client parses each frame and switches on `.type`.

```text title="SSE frames for a successful two-node run" theme={null}
data: {"type": "metadata", "data": {"run_id": "run_7f3a", "thread_id": "chat_42", "workflow_name": "My Flow", "workflow_version": 3, "workflow_type": "workflow"}}

data: {"type": "node_update", "node": "node_llm_1", "output": {"node_llm_1": "Hello world"}}

data: {"type": "node_update", "node": "node_http_2", "output": {"node_http_2": {"status_code": 200, "body": {"ok": true}}}}

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

Note that the live wire is **flat** — `node_update` carries `node` and `output` keys, not the `node_id`/`status` fields of the internal typed model. Build clients against the wire shape shown here. A `{"type": "heartbeat"}` keepalive is injected every 15 seconds so the connection never idles closed, and on reconnect the stream replays the run's buffered history before tailing live. The full event taxonomy, interrupt and resume frames, and reconnection semantics are documented on [SSE run streaming](/realtime/sse-streaming); for workflow concepts and the distinct run-id identities, see [Workflows & runs](/concepts/workflows-and-runs).

### Failures and credits

There is no fixed per-node credit charge. Charging happens inline: LLM and agent calls meter token usage, and managed knowledge retrieval reserves and records [credits](/billing/credits) per call. A mid-run hard stop — for example, exhausted credits — surfaces as a node error carrying a machine-readable reason code, so a client can match it deterministically. The managed-knowledge billing gate is best-effort inside a run: a billing hiccup is recorded but does not crash a workflow that is already running.

For node-level retry behavior, how failures surface in the stream, and debugging failed runs, see [Error handling & retries](/workflow-builder/error-handling-retries). For where and how managed usage is gated, see [Usage gating & limits](/billing/usage-gating).

## Next steps

<CardGroup cols={2}>
  <Card title="Node types overview" icon="boxes" href="/workflow-builder/nodes/overview">
    Every node type with its full parameter reference.
  </Card>

  <Card title="Variables & references" icon="braces" href="/workflow-builder/variables-and-references">
    The complete `{{nodeId.path}}` reference and run-state cookbook.
  </Card>

  <Card title="Workflows & runs" icon="play" href="/concepts/workflows-and-runs">
    What a run is, its status model, and the three run-id identities.
  </Card>

  <Card title="SSE run streaming" icon="radio" href="/realtime/sse-streaming">
    The full run-event taxonomy and how to consume it.
  </Card>
</CardGroup>
