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

# Run a workflow from the builder

> Run the live canvas from the builder, watch every node stream over SSE, handle interrupts, errors, retries, and billing denials, and review the durable run history.

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 **Run** button in the builder executes the workflow currently on your canvas and streams
every node back to the canvas live. This page covers what the Run button actually does, every
event you receive over the live stream, how interrupts, errors, retries, and billing denials
appear, and how to read a run after it finishes — both in the app and from code.

For the underlying transport (frame format, reconnection, history replay), see
[SSE run streaming](/realtime/sse-streaming). For how individual node failures surface and how
to configure retries, see [Error handling & retries](/workflow-builder/error-handling-retries).

## What "Run" does

Pressing **Run** executes an **ad-hoc run** of the live canvas schema — not a deployed
version. The builder sends the current schema inline with `ephemeral: true`, so the run does
not create a persistent chat record, and stamps `attribution_workflow_id` so the run still
appears in this workflow's run history. The canvas schema itself is what runs; attribution is
metadata only.

This is different from running a saved [deployment](/workflow-builder/execution/deploy) by
`workflow_id`, and different from running over the API. Each path resolves the schema
differently:

| Path                                                                           | How it runs                                                  | Schema source                  |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------ |
| **Run** button (builder)                                                       | Ad-hoc, `ephemeral: true`, `attribution_workflow_id` stamped | The live canvas, sent inline   |
| [Run via API](/workflow-builder/execution/api-endpoint) by `workflow_id`       | Loads the workflow's **live deployment** snapshot            | The deployed schema            |
| [Run via API](/workflow-builder/execution/api-endpoint) with inline `workflow` | Ad-hoc                                                       | The schema in the request body |

<Note>
  Running by `workflow_id` requires an active deployment. If the workflow has no live
  deployment, the run returns **400** with the message `Workflow has no active deployment.
    Deploy the workflow first...`. The **Run** button avoids this entirely because it sends the
  canvas schema inline. To run a saved version by id, [deploy first](/workflow-builder/execution/deploy).
</Note>

<MediaEmbed id="MX-MEDIA-3160" type="app_video" caption={"Pressing Run on the builder canvas and watching nodes light up live."} />

## Run controls

The run controls live on the builder canvas. Before a run starts the builder validates the
canvas and resets node state.

<Steps>
  <Step title="Provide run input">
    If the workflow's `state_schema` declares input fields, the builder shows a run-input form.
    Each value is parsed to its declared field type — `string`, `integer`, `float`, `boolean`,
    `object`, or `array` — with a JSON fallback for `object` and `array`. Reference tokens like
    `{{node_id.field}}` are resolved from run state at execution time, not at submit time; see
    [Variables & references](/workflow-builder/variables-and-references).
  </Step>

  <Step title="Press Run">
    Every non-start node is set to `pending`, prior outputs are cleared, and the canvas opens a
    live stream. The builder keeps a rolling buffer of the most recent run events so it can show
    you context if the run fails.
  </Step>

  <Step title="Watch the stream">
    Nodes transition `pending → running → completed` (or `error`) as events arrive. Open the
    [Detail Panel](/workflow-builder/canvas) on any node to inspect its streamed output.
  </Step>

  <Step title="Stop or resume">
    **Stop** cancels a `running` or `interrupted` run gracefully — the current node finishes,
    then the run stops between nodes. If the run pauses at an
    [interrupt node](/workflow-builder/nodes/interrupt), the builder shows a form; submitting it
    **resumes** the same run.
  </Step>
</Steps>

<Tip>
  The builder validates the canvas before it lets you run: there must be at least one non-start
  node, the start node must have an outgoing edge, no node may be unreachable from `__start__`,
  and every node that needs a credential must have one connected. Validation errors point you at
  the offending node (it opens the Detail Panel or focuses the node).
</Tip>

## The run lifecycle

A run is asynchronous. The Run button issues a request that returns immediately with a
`run_id`, then the run executes in the background while you watch it over a separate live
stream. Under the hood the builder drives four operations:

```text theme={null}
run     POST /workflows/run                 ──▶  starts the run, returns run_id immediately
listen  GET  /workflows/listen/{run_id}     ──▶  SSE: every node event, live
resume  POST /workflows/resume/{thread_id}  ──▶  continues a run paused at an interrupt (same run_id)
cancel  POST /workflows/cancel/{run_id}     ──▶  graceful stop, between nodes
```

Key identifiers you will see:

<ParamField path="run_id" type="string">
  The per-execution identifier used for the live stream, cancellation, and the durable run
  record. The Run button starts one run with one `run_id`; **resuming after an interrupt reuses
  the same `run_id`** (one logical run, charged once). This differs from the Assistant and
  Composer, where a resume mints a *new* `run_id`. See
  [Workflows & runs](/concepts/workflows-and-runs) for the three distinct run-id identities.
</ParamField>

<ParamField path="thread_id" type="string">
  The conversation/checkpoint thread. For a non-ephemeral run `thread_id == chat_id`; for the
  builder's ad-hoc (`ephemeral: true`) run, `chat_id` is `null` and `thread_id` is a fresh
  identifier. `resume` is addressed by `thread_id`, not `run_id`.
</ParamField>

<ParamField path="run_pk" type="string">
  The run record's `id` in run history. This is **not** the same value as
  `run_id` — it is the `id` returned by `GET /workflow-runs`. Use it to fetch a single run
  detail.
</ParamField>

## Live SSE output

The live stream is delivered over [Server-Sent Events](/realtime/sse-streaming) on
`GET /workflows/listen/{run_id}`. Every frame is a bare data-only line — `data: {json}` with a
blank line after it — and there is **no** SSE `event:` field. You discriminate on the JSON
`type` key. Multiple clients can listen to the same `run_id` at once, and a reconnect replays
the run's buffered history (1-hour retention) before tailing live frames, so reconnecting
mid-run is safe.

<Warning>
  The wire payloads below are the shapes the **executor actually publishes**. ModuleX also ships
  typed event models and generated TypeScript types, but those disagree with the wire on several
  fields — most notably `node_update`, whose wire key is `node` (the node id), not `node_id`.
  Always read the field names documented here, not the typed models.
</Warning>

### Event types

<ResponseField name="metadata" type="event">
  First event of the run. Wrapped under `data`:
  `{type, data: {run_id, thread_id, workflow_name, workflow_version, workflow_type, timestamp}}`.
  `workflow_type` is `workflow` for canvas runs.
</ResponseField>

<ResponseField name="node_started" type="event">
  A node has begun. Flat shape: `{type, node, name, timestamp, metadata}`. The canvas uses this
  to flip the node to `running`.
</ResponseField>

<ResponseField name="node_update" type="event">
  A node produced output (the graph-loop result). **Flat** shape:
  `{type, node, output}`, where `node` is the node id and `output` is the serialized state delta
  or message list. The [knowledge node](/workflow-builder/nodes/knowledge) publishes a richer
  `output` object (match count, provider, top score, sources, and timing).
</ResponseField>

<ResponseField name="node_retry" type="event">
  A node attempt failed and will be retried. Carries the attempt counter and the computed
  backoff delay: `{type, node, name, attempt, max_attempts, error_type, error_message, next_retry_in, timestamp}`.
  See [Retries](#retries-and-node-errors).
</ResponseField>

<ResponseField name="node_error" type="event">
  A node exhausted its retries or hit a non-retryable error. The node then re-raises, which
  fails the run: `{type, node, name, error_type, error_message, reason, attempt, max_attempts, recoverable, timestamp}`.
</ResponseField>

<ResponseField name="interrupt" type="event">
  The run reached an [interrupt node](/workflow-builder/nodes/interrupt) and is asking a human a
  question. Wrapped under `data`:
  `{type, data: {thread_id, message, data, resume_schema, examples}}`. This event does **not**
  close the stream — the connection stays open so the canvas can render the form. Respond with
  `resume`. See [Human-in-the-loop resume](/realtime/hitl).
</ResponseField>

<ResponseField name="resumed" type="event">
  Confirms a resume took effect (published by the API, not the executor). Wrapped:
  `{type, data: {run_id, thread_id, resume_value, timestamp}}`. The same `run_id` continues.
</ResponseField>

<ResponseField name="heartbeat" type="event">
  A keepalive emitted after roughly 15 seconds of silence: `{type: "heartbeat"}` with no other
  fields. It is not a workflow event — ignore it. It keeps a long interrupt pause from
  idle-closing the connection.
</ResponseField>

<ResponseField name="done" type="event">
  Terminal success. Wrapped: `{type, data: {message: "Workflow completed successfully"}}`. The
  stream closes after this frame.
</ResponseField>

<ResponseField name="error" type="event">
  Terminal failure. **Flat** shape: `{type, message}`, for example
  `{"type":"error","message":"Execution failed: ..."}`. The stream closes after this frame.
</ResponseField>

<ResponseField name="cancelled" type="event">
  Terminal cancellation, after **Stop**. Wrapped:
  `{type, data: {run_id, reason, cancelled_at}}`. The `data` may be `null` if the cancel record
  has already expired. The stream closes after this frame.
</ResponseField>

The terminal events that close the live stream are `done`, `error`, and `cancelled`. An
`interrupted` marker exists only in the run's replay history (it is never sent live), so that a
later reconnect stops cleanly at the pause point.

### Raw frame trace

A run that reaches an interrupt, is resumed, and then completes looks like this on the wire
(blank line between frames omitted for brevity except where shown):

```text theme={null}
data: {"type":"metadata","data":{"run_id":"6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d","thread_id":"550e8400-e29b-41d4-a716-446655440000","workflow_name":"Research","workflow_version":"1.0","workflow_type":"workflow","timestamp":"2026-06-21T10:00:00.000000+00:00"}}

data: {"type":"node_update","node":"plan","output":{"messages":[{"type":"ai","content":"..."}]}}

data: {"type":"heartbeat"}

data: {"type":"interrupt","data":{"thread_id":"550e8400-e29b-41d4-a716-446655440000","message":"Approve this plan?","data":{},"resume_schema":{"type":"object","properties":{"approved":{"type":"boolean"}}}}}

data: {"type":"resumed","data":{"run_id":"6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d","thread_id":"550e8400-e29b-41d4-a716-446655440000","resume_value":{"approved":true},"timestamp":"2026-06-21T10:00:30.000000+00:00"}}

data: {"type":"node_update","node":"summarize","output":{"summary":"..."}}

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

A failed run ends with `data: {"type":"error","message":"Execution failed: <msg>"}` instead of
`done`. A cancelled run ends with a `cancelled` frame.

## Errors

A run can fail before it starts, while it executes, or because the caller is denied by billing.
Each surfaces differently.

### Start-time errors

These are returned by `POST /workflows/run` before any stream opens — the Run button shows them
as a toast or dialog. They use the standard `{detail}` envelope unless noted.

<ResponseField name="400" type="status">
  Bad request. Common causes: a malformed `state_schema` input, or running by `workflow_id`
  with no live deployment (`Workflow has no active deployment. Deploy the workflow first...`).
</ResponseField>

<ResponseField name="404" type="status">
  The workflow or run could not be found in your organization. Cross-organization ids return
  404, never another org's data — `{"detail":"Workflow not found"}` /
  `{"detail":"Run not found"}`.
</ResponseField>

<ResponseField name="410" type="status">
  Gone. Returned if you send only an `llm` body (the removed LLM-only mode). Use the
  [Assistant](/assistant/overview) instead.
</ResponseField>

<ResponseField name="402 / 403 / 429" type="status">
  A billing denial — see [Billing denials](#billing-denials-402-403-429). These are real and
  live on the run surface.
</ResponseField>

<ResponseField name="500" type="status">
  An internal error, including a malformed `workflow_schema` (which surfaces as 500, not 422):
  `{"detail":"Failed to start workflow: ..."}`.
</ResponseField>

### Run-time errors

Once the run is streaming, a node failure arrives as a `node_error` event followed by a
run-level `error` event that closes the stream. The durable run record is then marked `failed`
with the error message. For per-node detail and how to debug a failed node, see
[Error handling & retries](/workflow-builder/error-handling-retries).

<Note>
  The live stream reports terminal success as a `done` event, but the durable run record stores
  the status as `succeeded` (not `completed`). Read `done`/`error`/`cancelled` from the stream,
  and `succeeded`/`failed`/`cancelled` from [run history](#run-history).
</Note>

### Billing denials (402 / 403 / 429)

Every charged run passes through a billing **admission gate** that runs *before* any run record
or background task is created (reject-before-write). When it denies the run, the response is a
**flat `DenialEnvelope`** — `{code, layer, key, current, limit, reason}` with **no `detail`
key** — and **no run is created and no stream opens**. The builder catches this and routes you
to the relevant upgrade or wallet action instead of starting a run.

The HTTP status depends on which layer denied the run:

| Status  | `layer`  | `code`                    | Meaning                                                                        |
| ------- | -------- | ------------------------- | ------------------------------------------------------------------------------ |
| **429** | `rate`   | `rate_limit_exceeded`     | Too many runs too quickly. Includes `Retry-After` and `X-RateLimit-*` headers. |
| **403** | `quota`  | `quota_exceeded`          | A plan quota was reached.                                                      |
| **402** | `credit` | `credit_plan_exhausted`   | The plan's credit allowance is exhausted and no wallet overage is available.   |
| **402** | `credit` | `upgrade_payment_failed`  | An upgrade proration charge failed.                                            |
| **402** | `wallet` | `wallet_overage_disabled` | Plan credits are exhausted and wallet overage is turned off.                   |
| **402** | `wallet` | `wallet_insufficient`     | Wallet overage is on but the balance is too low.                               |

Example 402 body:

```json theme={null}
{
  "code": "credit_plan_exhausted",
  "layer": "credit",
  "key": "credit:org:11111111-1111-1111-1111-111111111111",
  "current": 1000.0,
  "limit": 1000.0,
  "reason": "credit_plan_exhausted"
}
```

<Note>
  This flat `DenialEnvelope` is live on the **run**, Composer, Assistant, and managed-knowledge
  surfaces only. Plain CRUD and org-settings routes use the `{detail}` envelope instead. For the
  full error-shape taxonomy see [Errors & status codes](/api-reference/errors); for what consumes
  credits and how the gate works see [Usage gating & limits](/billing/usage-gating).
</Note>

## Retries and node errors

Most node types are wrapped in a retry layer. When an attempt fails with a retryable error
type, the executor waits with exponential backoff and tries again, publishing a `node_retry`
event for each retry. When retries are exhausted (or the error is not retryable) it publishes
`node_error`, the node re-raises, and the run fails.

Retries are configured per node with `retry_config`. When `retry_config` is omitted, a node
defaults to 2 retries (3 total attempts).

<Expandable title="retry_config fields">
  <ParamField path="max_attempts" type="integer" default="3">
    Total attempts, including the first. `1` means no retry; `3` means the first attempt plus 2
    retries. Range: 1–10.
  </ParamField>

  <ParamField path="initial_interval" type="float" default="1.0">
    Delay before the first retry, in seconds. Range: 0.1–60.0.
  </ParamField>

  <ParamField path="backoff_factor" type="float" default="2.0">
    Multiplier applied to the delay between retries (exponential backoff). Range: 1.0–5.0.
  </ParamField>

  <ParamField path="retry_on_error_types" type="string[]" default="[&#x22;TimeoutError&#x22;, &#x22;ConnectionError&#x22;, &#x22;HTTPError&#x22;]">
    The error class names that trigger a retry. An error not in this list fails the node
    immediately, with no retry.
  </ParamField>
</Expandable>

<Note>
  Two node types are intentionally **not** retry-wrapped: the
  [interrupt node](/workflow-builder/nodes/interrupt) (it pauses for a human and must never
  auto-retry) and the [function node](/workflow-builder/nodes/function) (it typically returns a
  soft-failure value rather than raising). See
  [Error handling & retries](/workflow-builder/error-handling-retries) for the full retry
  contract and debugging guidance.
</Note>

## Credit impact

A successful or failed run is charged **exactly one run credit**, recorded once per `run_id`.
Resuming an interrupted run does **not** charge again — the gate's reservation is keyed on the
`run_id`, so the resume is idempotent. Language-model **token** usage is metered separately
inside the executor regardless of whether the run succeeds, fails, or is cancelled. See
[Credits & metering](/billing/credits).

## Run history

Every run is persisted to a durable run history, independent of the live stream. In the builder,
the **Runs** panel lists this organization's runs for the current workflow; each row links to a
full run detail. Run history survives even if the workflow is later deleted.

Two read endpoints back the panel:

<ResponseField name="GET /workflow-runs" type="endpoint">
  Lists runs, newest first, scoped to your organization. Optional filters: `workflow_id`,
  `status`, `trigger_type`, plus `limit` (default 50, max 100) and `offset`. The response sets
  `has_more` from a `limit + 1` fetch — there is no total count. Each row is a light summary:
  `id` (the `run_pk`), `run_id`, `workflow_id`, `trigger_type`, `is_ad_hoc`, `status`,
  `started_at`, `completed_at`, `duration_seconds`, `error_message`, `created_at`, `has_output`.
</ResponseField>

<ResponseField name="GET /workflow-runs/{run_pk}" type="endpoint">
  Returns the full run detail for a single run, including `input_snapshot` and `output_summary`.
  The path parameter is the run record's `id` (`run_pk`), **not** the executor `run_id`.
</ResponseField>

A run's `status` in history is one of `pending`, `running`, `succeeded`, `failed`, `cancelled`,
`interrupted`, or `skipped`. Its `trigger_type` is one of `manual` (a builder/JWT run),
`api_key`, `scheduled`, or `composer` — a Run-button run is `manual`.

## Run the same workflow from code

The Run button is one of three [run surfaces](/concepts/workflows-and-runs). To run a workflow
programmatically and stream its events, call `POST /workflows/run` and then listen on the
returned `run_id`. Authenticate every request with `Authorization: Bearer mx_live_…` and
`X-Organization-ID`.

<CodeGroup>
  ```bash cURL theme={null}
  # 1. Start the run (returns immediately with a run_id)
  curl -X POST https://api.modulex.dev/workflows/run \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111" \
    -H "Content-Type: application/json" \
    -d '{
      "workflow_id": "550e8400-e29b-41d4-a716-446655440000",
      "input": {"query": "AI trends"},
      "config": {"recursion_limit": 50}
    }'

  # 2. Stream the run live (data-only SSE; discriminate on the JSON "type")
  curl -N https://api.modulex.dev/workflows/listen/6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111"
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import AsyncModulex, NotFoundError

  client = AsyncModulex(api_key="mx_live_xxx", organization_id="11111111-1111-1111-1111-111111111111")

  async def main():
      run = await client.executions.run(
          workflow_id="550e8400-e29b-41d4-a716-446655440000",
          input={"query": "AI trends"},
          idempotency_key="research-4823",   # safe to retry the start call
      )
      print(run.run_id, run.status)

      try:
          async for event in client.executions.listen(run.run_id):
              if event.event == "node_update":
                  print(event.data["node"], event.data.get("output"))
              elif event.is_terminal:
                  break
      except NotFoundError:
          # A run your org does not own returns 404 on connect — do not reconnect.
          print("Run not found or not owned by this organization.")

  asyncio.run(main())
  ```

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

  const client = new Modulex({
    apiKey: "mx_live_xxx",
    organizationId: "11111111-1111-1111-1111-111111111111",
  });

  const run = await client.executions.run({
    workflowId: "550e8400-e29b-41d4-a716-446655440000",
    input: { query: "AI trends" },
    stream: true,
  });
  console.log(run.run_id, run.status);

  for await (const event of client.executions.listen(run.run_id)) {
    if (event.type === "node_update") console.log(event.node, event.output); // flat frame
    if (event.type === "done") console.log("finished:", event.data.message); // wrapped frame
    if (event.type === "error") console.error(event.message);
  }
  ```
</CodeGroup>

<Note>
  Running by `workflow_id` over the API loads the workflow's **live deployment** snapshot, not the
  canvas — [deploy](/workflow-builder/execution/deploy) first, or send an inline `workflow` schema
  to run ad-hoc. The same `402 / 403 / 429` [billing denials](#billing-denials-402-403-429) apply.
  Note that `idempotency_key` makes the *start call* safe to retry on the client, but it does not
  deduplicate runs server-side. See [Run via API](/workflow-builder/execution/api-endpoint) for
  the full request contract.
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="SSE run streaming" icon="radio" href="/realtime/sse-streaming">
    The transport in depth: frame format, reconnection, and history replay.
  </Card>

  <Card title="Error handling & retries" icon="rotate-ccw" href="/workflow-builder/error-handling-retries">
    Per-node retry configuration and how to debug a failed run.
  </Card>

  <Card title="Deploy & versions" icon="rocket" href="/workflow-builder/execution/deploy">
    Save a deployment so you can run a fixed version by id.
  </Card>

  <Card title="Run via API" icon="terminal" href="/workflow-builder/execution/api-endpoint">
    Trigger and stream a run from your own backend.
  </Card>
</CardGroup>
