Skip to main content
This guide takes you end to end: authenticate a request, start a workflow run with POST /workflows/run, and stream its events over Server-Sent Events (SSE). Every operation is shown three ways — cURL, Python, and JavaScript — so you can wire it into a script or a server in minutes. A run is asynchronous. The run endpoint returns immediately with a run_id while the workflow executes in the background; you then open an SSE stream on that run_id to watch nodes execute and receive the final result. For the full event taxonomy and frame format, see SSE run streaming. For base URLs, the request lifecycle, and how operations map across surfaces, see the API overview.
Before you start, you need three things: an mx_live_* API key, your organization ID, and a workflow with an active deployment (or an inline workflow definition). API keys are created in the dashboard at https://app.modulex.dev; see the Quickstart and Authentication.

Prerequisites

Step 1 — Authenticate

Every request carries two headers. This is the same scheme across REST, the Python SDK, and the JavaScript SDK — there is no X-Authorization header.
string
required
Bearer mx_live_… — your API key. The backend also accepts the key in an X-API-KEY header, but Authorization: Bearer is the documented form. A missing or invalid token returns 401 with a WWW-Authenticate: Bearer header.
string
required
The organization (tenant) the run belongs to. Required on every org-scoped route. If it is missing, the request returns 400 with {"detail": "X-Organization-ID header is required"}.
string
application/json for the POST /workflows/run body.
The SDKs take the key and organization ID once, at construction, and attach both headers to every call. The Python SDK additionally falls back to the MODULEX_API_KEY, MODULEX_BASE_URL, and MODULEX_ORGANIZATION_ID environment variables; the JavaScript SDK has no environment-variable fallback — you must pass the values to the constructor. See the JavaScript SDK and Python SDK pages for the full configuration surface.

Step 2 — Start the run

Send POST /workflows/run. The endpoint admits the run through the billing gate, returns a 200 with run metadata, and executes the workflow in a background task. Pick exactly one execution mode.

Execution modes

string
Run a saved workflow. The backend loads the schema from the workflow’s active deployment snapshot. If the workflow has no live deployment, the run returns 400 (Workflow has no active deployment. Deploy the workflow first…). Request input overrides the deployment default; request config merges over the deployment config.
object
Run an inline, ad-hoc WorkflowDefinition without saving it. Sets is_ad_hoc: true on the run record. Use attribution_workflow_id to make an ad-hoc run appear in a saved workflow’s Runs panel without switching to deployment-load mode.
string
Run a built-in system workflow by name. In this mode, both input and config are required (otherwise 400). Exposed by the Python SDK and cURL; the JavaScript SDK does not surface this mode.
There is no LLM-only run mode. A request that carries only an llm config (no workflow, workflow_id, or system_workflow) returns 410 Gone. Use the Assistant instead — POST /assistant/chat, documented in Streaming responses.

Other body fields

object
default:"{}"
State input values passed to the workflow’s entry node. Keys match your workflow’s state_schema. Required for system_workflow mode.
object
default:"{}"
Runtime overrides for this execution. Recognized keys: thread_id, recursion_limit, and batch_interval_ms. Required for system_workflow mode.
boolean
default:true
Echoed back in the response. Run events are always consumed by opening the SSE stream in Step 3; this flag does not change that.
boolean
default:false
When true, no chat record is created, chat_id is null, and thread_id is a fresh value. When false, thread_id equals chat_id (the same UUID).
boolean
default:false
When true, the run and its messages are visible only to the creator.
string
For ad-hoc (inline) runs, the saved workflow to attribute this run to in run history.
The Python SDK accepts an idempotency_key argument on executions.run(...). It is sent as an Idempotency-Key header, but POST /workflows/run assigns its own run_id, so it does not de-duplicate runs. The JavaScript SDK does not send it. See SDK errors & retries.

Request

The JavaScript SDK takes camelCase parameter names (for example workflowId) and converts them to snake_case on the wire — but responses stay snake_case (run.run_id, run.thread_id). The Python SDK is snake_case in both directions.

Response

A 200 is returned immediately while the workflow runs in the background. The status here reflects only the synchronous portion — it is running, never a terminal status. Terminal outcomes arrive on the SSE stream (Step 3) or via run history (Step 5).
200 — run started
string
Always running for a started run. Terminal status is observed via the stream or run history.
string
The per-execution identifier you pass to GET /workflows/listen/{run_id}. This is also the durable run identifier reused on resume. It is not the run record’s id (returned by list/get) — see Workflows & runs for the three distinct run-id identities.
string
The conversation/checkpoint thread. Equals chat_id when ephemeral is false. Used by GET /workflows/state/{thread_id} and POST /workflows/resume/{thread_id}.
string | null
The chat record for this run, or null when ephemeral is true.
string
Origin of the executed definition: database, request, or system:<name>.
object | null
The persisted human message, or null for ephemeral runs (or if chat creation failed).
object | null
The persisted AI message stub (running_status: "running"), or null for ephemeral runs.

Run errors

error
Missing input/config for system_workflow mode; a workflow_id with no active deployment; or no execution mode supplied. Shape: {"detail": "<message>"}.
error
The system workflow file does not exist, or the workflow_id is not in your organization. Cross-tenant IDs return 404 (not 403), so they do not leak existence. Shape: {"detail": "..."}.
error
An llm-only request — LLM mode was removed. Use POST /assistant/chat.
error
Billing gate denial. See Handle the billing gate below — these responses are live on this route.
error
Wrapped failure ({"detail": "Failed to start workflow: …"}). Because the run body is an untyped object, a malformed inline workflow schema also surfaces as 500 rather than 422.

Step 3 — Stream the run

Open GET /workflows/listen/{run_id} to receive run events over SSE. The stream is data-only: each frame is data: <json>\n\n with no event: line. The event type is the type key inside the JSON. On (re)connect, the buffered run history (1-hour TTL) is replayed in order first, then the live tail — so reconnecting mid-run is replay-safe. An ownership guard runs before the stream opens; an unknown or cross-tenant run_id returns 404 {"detail": "Run not found"} as a normal HTTP response (not an in-stream frame). The full frame format, reconnection, and heartbeat mechanics live on SSE run streaming.

Event types on this stream

Document the wire shapes below — they are what a live client receives. The typed event models and generated TypeScript types describe a different, stale shape (for example, a typed node_update uses node_id/status, but the wire frame uses node/output).
event
First frame. data: {run_id, thread_id, workflow_name, workflow_version, workflow_type, timestamp}.
event
Emitted as each node produces output. Flat shape: {type, node, output} where node is the node name and output is the serialized state delta.
event
Emitted by some nodes (for example, the knowledge node) before they run: {type, node, name, timestamp, metadata}.
event
The workflow hit an interrupt (HITL) node and is awaiting input. The stream stays open. data: {thread_id, message, data, resume_schema?}. Resume with POST /workflows/resume/{thread_id} — see Step 5 and Human-in-the-loop resume.
event
Published after a resume; the run continues under the same run_id. data: {run_id, thread_id, resume_value, timestamp}.
event
A {"type": "heartbeat"} keepalive emitted after 15 seconds of silence so a long interrupt pause does not idle-close the connection. Treat it as a no-op.
event
Terminal. data: {message: "Workflow completed successfully"}. Closes the stream.
event
Terminal. Flat shape: {type, message} (for example, Execution failed: …). Closes the stream.
event
Terminal. data: {run_id, reason, cancelled_at} (or data: null if the cancel blob already expired). Closes the stream.
The durable status string differs from the live event. The SSE done event corresponds to a durable run status of succeeded (not completed or done) when you read it back from run history in Step 5.

Raw frame trace

A run that hits an interrupt, is resumed, then completes looks like this on the wire (no event: lines):
Wire frames

Step 4 — Handle the billing gate

Managed runs pass through a billing admission gate before any database write. On denial, the run is rejected with no run record and no background task, and the response is a flat DenialEnvelopenot the {"detail": …} shape. The envelope has no detail wrapper:
DenialEnvelope (402 example)
The layer field maps to the HTTP status:
These billing responses are live on the run, composer, assistant, and managed-knowledge surfaces. They are absent on plain CRUD and org-settings routes, which return the {"detail": "<string>"} HTTPException shape instead. For the complete picture of all error-envelope shapes and which surface emits each, see Errors & status codes; for credit mechanics and the gate, see Usage gating & limits.
The two SDKs map these differently. The Python SDK raises PaymentRequiredError for 402 (with subclasses CreditExhaustedError, WalletError, QuotaExceededError), PermissionError-style for 403, and RateLimitError for 429. The JavaScript SDK has no payment error class — 402 falls through to the base ModulexError; 429 maps to its rate-limit error. See SDK errors & retries.

Step 5 (optional) — Resume an interrupt and read history

If your workflow contains an interrupt node, the stream emits an interrupt event and waits. Inspect the checkpoint with GET /workflows/state/{thread_id}, then resume with POST /workflows/resume/{thread_id}, passing resume_value, the same run_id, and a schema source (workflow_id or inline workflow). Resume reuses the same run_id, so the run is charged only once — re-open the stream on the same run_id to keep watching.
cURL — resume
After a run finishes, fetch durable history with GET /workflow-runs (list) and GET /workflow-runs/{run_pk} (detail, including input_snapshot and output_summary).
The detail route’s path parameter run_pk is the run record’s id (returned by list/get), not the execution run_id you streamed. They are different identifiers — see Workflows & runs.
Run history is grouped differently across the SDKs: the JavaScript SDK exposes it as client.workflowRuns, while the Python SDK folds it into client.executions (list_runs / iter_runs / get_run). Both call the same GET /workflow-runs routes. See the SDK parity matrix.

Next steps

SSE run streaming

The full event taxonomy, frame format, heartbeats, and reconnect semantics.

API overview

Base URLs, the request lifecycle, and how every operation is shown three ways.

Human-in-the-loop resume

Pause and resume runs that ask a human for input or approval.

Errors & status codes

All three error-envelope shapes and which surface emits each.