POST to start a run, then open a Server-Sent Events (SSE) stream to watch it finish. Every operation is shown once as a <CodeGroup> with cURL, Python, and JavaScript tabs.
If you have not yet created an organization and an API key, start with the Quickstart and come back here. For the full request lifecycle, base URLs, and the unified SDK reference, see the API overview and the SDKs overview.
You need a saved workflow with an active deployment to run it by
workflow_id. Deploying a workflow snapshots its schema and marks one deployment live; POST /workflows/run loads that live snapshot. Build and deploy one first in the workflow builder, then run it here. You can also run an inline ad-hoc definition — see Running by ID vs. inline definition.Before you begin
You need three things:1
An API key
A user API key with the
mx_live_ prefix. Create one in the app under your organization’s API Keys settings, or with POST /api-keys. Treat it like a password — it carries your organization’s permissions.2
Your organization ID
The UUID of the organization the call runs against. Every org-scoped request requires it in the
X-Organization-ID header. Retrieve it from GET /auth/me (the primary_organization_id field) or GET /organizations.3
A deployed workflow
A workflow with an active deployment, identified by its
workflow_id. If the workflow has no live deployment, POST /workflows/run returns 400 — deploy it first.https://api.modulex.dev. Routers mount at the root, so there is no /v1 or /api path segment — the run endpoint is exactly https://api.modulex.dev/workflows/run. See Base URLs, environments & versioning for details.
Authentication
Authenticate every request with two headers:string
required
Bearer mx_live_<key>. The same scheme carries either a user API key (mx_live_ prefix) or a Clerk JWT (browser sessions); the backend distinguishes them by the prefix. For programmatic calls, use your mx_live_ key.string
required
The organization UUID the request is scoped to. Required on org-scoped endpoints, including
/workflows/run. If it is missing, the request fails with 400 and {"detail": "X-Organization-ID header is required"}.string
Optional alternative to
Authorization: Bearer for the API key. Send either one, not both. The SDKs do not use this header.string
required
application/json on the POST body. Omit it on the SSE GET (the listen call has no body).POST /workflows/run requires the caller to be an owner or admin of the organization. A non-admin caller receives 403. The member role is retired and is not a current first-class role — see Roles & permissions.
Install an SDK
The cURL examples need no dependencies. To use a typed client, install one of the official SDKs.MODULEX_API_KEY, MODULEX_BASE_URL, and MODULEX_ORGANIZATION_ID from the environment when the matching constructor argument is omitted. The JavaScript SDK has no environment-variable fallback — pass apiKey (and organizationId) to the constructor explicitly. Both default baseUrl to https://api.modulex.dev. This divergence is documented on the JavaScript SDK and Python SDK pages.
Step 1 — Start a run
Send aPOST to /workflows/run with the workflow_id of your deployed workflow and the input your workflow’s state expects. The request returns immediately — the run executes in the background — so the response status is running, not a final result.
In the SDKs this operation is client.executions.run(...) (the execution-control methods live under /workflows on the backend but are grouped as executions in both clients).
In the JavaScript SDK, request parameters are camelCase (
workflowId, recursionLimit) and are converted to snake_case on the wire — but response fields stay snake_case (run.run_id, run.thread_id). The Python SDK is snake_case in both directions. This is why the JS example reads run.run_id, not run.runId.Request body
The/workflows/run body is a JSON object. Provide exactly one execution mode (workflow_id, workflow, or system_workflow); the rest of the fields are optional.
string
The UUID of a saved workflow. ModuleX loads that workflow’s live deployment snapshot. Returns
400 if the workflow has no active deployment. Mutually exclusive with workflow and system_workflow.object
An inline ad-hoc workflow definition (a full
WorkflowDefinition) to run without saving it first. The run is marked is_ad_hoc=true. Mutually exclusive with workflow_id and system_workflow.string
The name of a built-in system workflow. When used, both
input and config are required (400 otherwise). Mutually exclusive with the other two modes.object
default:"{}"
The initial state passed to the workflow’s entry node, keyed by your
state_schema fields.object
default:"{}"
Per-run execution config. Recognized keys:
thread_id (string, reuse a checkpoint thread), recursion_limit (integer, caps graph steps; the workflow’s own default is 500), and batch_interval_ms (integer, event batching cadence). When running by workflow_id, these merge over the deployment’s stored config.boolean
default:"true"
Echoed back in the response. Run events are always observed by opening the SSE stream in step 2; this flag does not toggle inline token streaming.
boolean
default:"false"
When
true, the run is not attached to a chat record: chat_id is null and thread_id is a fresh UUID.boolean
default:"false"
When
true, scopes the created chat as private. Sent as is_private on the wire (isPrivate in the JS SDK).string
Attributes an inline (
workflow) run to a saved workflow’s run history without switching to deployment-load mode.Response
A successful start returns200 with the run metadata. The run is not finished — you receive identifiers to track it.
string
Always
"running" on a successful start. The terminal outcome arrives over the SSE stream, not in this response.string
The per-execution identifier. Use it to listen, cancel, and look up the run. This is the id you pass to
GET /workflows/listen/{run_id}.string
The checkpoint thread. Equals
chat_id when ephemeral=false. Use it for GET /workflows/state/{thread_id} and to resume after an interrupt.string | null
The chat the run is attached to, or
null for an ephemeral run.string
Where the executed schema came from:
database, request (inline), or system:<name>.string
The name of the workflow being executed.
string
The version label of the executed workflow.
number
Wall-clock time spent setting up the run before responding.
object | null
The persisted user message envelope, or
null for an ephemeral run.object | null
The persisted assistant message envelope (with
running_status), or null for an ephemeral run.Running by ID vs. inline definition
Most calls run a saved, deployed workflow byworkflow_id. To run a definition without saving it, send it inline under workflow instead.
Inside the inline
workflow definition, field names are already snake_case (state_schema, entry_point) in the JavaScript SDK too — only the outer parameters use camelCase. For the complete WorkflowDefinition contract, see Workflow engine & nodes and Variables & references.Step 2 — Stream the result
OpenGET /workflows/listen/{run_id} to receive run events over SSE. The stream replays any buffered history first, then tails live, and closes after a terminal event (done, error, or cancelled). Multiple clients can listen to the same run_id concurrently.
In the SDKs this is client.executions.listen(run_id), which yields parsed events you iterate over.
The two SDKs surface the event discriminator differently. The Python
SSEEvent normalizes it to event.event and exposes an is_terminal property; the JavaScript event uses event.type. Both read the same underlying wire field. The Python SDK filters heartbeat frames out by default; the JavaScript SDK skips the SSE comment lines used as keepalives.SSE frame format
Run events are data-only SSE frames: each frame isdata: {json}\n\n with no event: line. The discriminator is the type key inside the JSON. Here is a raw stream for a run that completes:
data: {"type":"error","message":"Execution failed: <message>"} instead of done; a cancelled run ends with data: {"type":"cancelled","data":{...}}.
Run event types
These are the event shapes the executor publishes on the wire. Note that some payloads are wrapped under adata key while others are flat alongside type — the table below reflects what a live client actually receives.
event (wrapped)
First frame.
data carries run_id, thread_id, workflow_name, workflow_version, workflow_type, and timestamp.event (flat)
A node produced output. Carries
node (the node name) and output (the serialized state delta or messages). The knowledge node emits a richer output object with retrieval details.event (flat)
A node began executing. Carries
node, name, timestamp, and metadata. Emitted by some node types (for example, knowledge).event (wrapped)
The run paused at an interrupt (human-in-the-loop) node.
data carries thread_id, message, and an optional resume_schema. This event does not close the live stream — resume with POST /workflows/resume/{thread_id} to continue. See Human-in-the-loop (HITL) resume.event (wrapped)
Emitted after a resume.
data carries run_id, thread_id, resume_value, and timestamp. A resumed workflow run keeps the same run_id.event (wrapped, terminal)
The run completed successfully.
data is {"message": "Workflow completed successfully"}. Closes the stream.event (flat, terminal)
The run failed. Carries a
message string. Closes the stream.event (wrapped, terminal)
The run was cancelled.
data carries run_id, reason, and cancelled_at (or is null if the cancel record already expired). Closes the stream.event (flat)
A
{"type":"heartbeat"} keepalive injected after every 15 seconds of silence so a long pause does not idle-close the connection. Not a workflow event — ignore it. The SDKs handle this for you.The durable run status uses different wording from the live stream: the SSE
done event corresponds to the durable status succeeded you see later in GET /workflow-runs. The live status string is completed; the persisted status is succeeded.Step 3 — Look up the run afterward
Once the stream closes, you can read the durable run record from history. List recent runs, then fetch one by itsid field (returned by list/get, not the run_id).
Run history is grouped differently in the two SDKs: the Python SDK folds it into
client.executions (list_runs / get_run), while the JavaScript SDK exposes a separate client.workflowRuns resource (list / get). Both call the same GET /workflow-runs routes.Errors
POST /workflows/run is a billing-gated surface. Besides the usual validation and auth errors, it can return a billing denial before any run is created.
Standard errors
These use the FastAPI envelope{"detail": "<message>"}.
Billing denials (the billing gate)
When your organization is over a credit, quota, or rate limit, the gate rejects the call before any run record, chat row, or background task is created. The response is a flatDenialEnvelope — note there is no detail wrapper:
layer:
In the SDKs, the Python client maps these to typed exceptions:
402 → PaymentRequiredError (or CreditExhaustedError / WalletError by layer), 403 → PermissionError (or QuotaExceededError), and 429 → RateLimitError carrying the retry headers. The JavaScript client maps 403 → PermissionError and 429 → RateLimitError; 402 falls through to the base ModulexError, where you can still read code, layer, and reason. See SDK errors & retries.
Retries and idempotency
The SDKs retry only safe, idempotent requests (GET/HEAD) on 429, 500, 502, and 503, with backoff that honors Retry-After. A POST /workflows/run is not auto-retried.
Next steps
Run a workflow (REST + SDK)
The full guide: authenticate, run, stream, and handle interrupts and the billing gate end to end.
SSE run streaming
The complete SSE frame format, event taxonomy, and reconnect/replay behavior.
SDKs overview
The JavaScript and Python clients, unified by operation, with the full parity matrix.
API overview
Base URLs, the request lifecycle, content types, and how every operation is shown three ways.