Skip to main content
This page runs a deployed workflow from code and streams its result, end to end, in three languages. You will send one authenticated 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.
The base URL is 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:
The auth header is Authorization: Bearer, not X-Authorization. There is no X-Authorization header anywhere in ModuleX. The backend accepts your API key as Authorization: Bearer mx_live_… or, alternatively, as X-API-KEY: mx_live_…. Both official SDKs send the Authorization: Bearer form. See Authentication for the complete model.
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.
The Python SDK is async-only and reads 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 a POST 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 returns 200 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.
The three identifiers above are not interchangeable, and ModuleX uses the word “run id” in three distinct senses. Pass the right one to each call:
  • run_id — the per-execution id for GET /workflows/listen/{run_id} and POST /workflows/cancel/{run_id}.
  • thread_id — the checkpoint thread for GET /workflows/state/{thread_id} and POST /workflows/resume/{thread_id}.
  • The run record’s id (the id field on a GET /workflow-runs row) — for GET /workflow-runs/{run_pk}. Passing the run_id there returns 404.
See Workflows & runs for the full breakdown.

Running by ID vs. inline definition

Most calls run a saved, deployed workflow by workflow_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

Open GET /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 is data: {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:
A failed run ends with 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 a data 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 its id 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 flat DenialEnvelope — note there is no detail wrapper:
The HTTP status is determined by the layer:
The flat DenialEnvelope shape is specific to billing-gated surfaces (run, composer, assistant, managed knowledge). Plain CRUD and org-settings routes do not return it — they return the {"detail": …} shape. Branch on both. The complete taxonomy of all three error-envelope shapes is on the Errors & status codes page; usage limits are detailed in Usage gating & limits.
In the SDKs, the Python client maps these to typed exceptions: 402PaymentRequiredError (or CreditExhaustedError / WalletError by layer), 403PermissionError (or QuotaExceededError), and 429RateLimitError carrying the retry headers. The JavaScript client maps 403PermissionError and 429RateLimitError; 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.
The Python SDK accepts an idempotency_key argument and sends it as the Idempotency-Key header on mutating calls, but POST /workflows/run mints its own run_id server-side and does not read that header — so it is a no-op for run de-duplication. Do not rely on Idempotency-Key to prevent duplicate runs. See SDK errors & retries.

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.