Skip to main content
When you run a workflow from chat, ModuleX records the run as a turn in a persistent chat thread: your workflow input becomes a human message, an ai placeholder message tracks progress, and the live node stream renders inline. This is the same POST /workflows/run execution path used by Run via API — the difference is the ephemeral flag. With ephemeral left at its default (false), the run is attached to a chat thread; with ephemeral: true, no chat row is created. This page is the technical reference for that chat-attached path: the exact request body, how inputs flow from chat into run state, the chat-message envelopes you get back, the SSE event stream you read to watch the run, and every error you can hit. For the end-user walkthrough of the same feature in the app, see Run a workflow from chat.
This page covers running a workflow as a chat turn. It is distinct from the agentic Assistant, which is a separate surface (POST /assistant/chat) that reasons and calls tools without a workflow. A request that carries only an llm config and no workflow returns 410 Gone with a pointer to the Assistant — see Errors below.

How a chat-triggered run works

A chat-triggered run is one POST /workflows/run call that resolves a workflow definition, creates or reuses a chat thread, writes two messages, charges one run credit, and starts background execution. You then open the SSE stream to watch it.
1

Resolve the workflow definition

The request resolves a definition in one of three modes (see Run modes). For a chat-triggered run you almost always use database mode — pass a workflow_id and the run loads the workflow’s live deployment. A workflow with no live deployment returns 400; deploy it first (see Deploy & versions).
2

Pass the billing gate

Before any rows are written, the run passes the billing admission gate. On denial it returns a 402 / 403 / 429 DenialEnvelope and nothing is created — no chat, no messages, no run record, no background task. See credit impact and Usage gating & limits.
3

Create or reuse the chat thread

With ephemeral: false (the default), the run creates a new chat or reuses an existing one when you pass a thread_id (see Inputs from chat). The chat title defaults to the workflow name, optionally suffixed with a preview of the input.
4

Write the human and AI messages

Your input is written verbatim as a human message. An ai placeholder message is created with running_status: "running", carrying a metadata content block (the run identity) and a snapshot of the workflow schema + input when small enough to inline.
5

Stream the run

The handler returns immediately with run_id, thread_id, chat_id, and both message envelopes. Open GET /workflows/listen/{run_id} to receive node_update, interrupt, done, error, and cancelled events as the run executes in the background. See Streaming back.

Identities you get back

A chat-triggered run produces three distinct identifiers. Keep them straight — they are not interchangeable. For the full model, see Workflows & runs.
string (UUID)
The execution identity, minted fresh on every run. Use it to stream (GET /workflows/listen/{run_id}) and to cancel (POST /workflows/cancel/{run_id}). A resume reuses the same run_id, so the run is charged once.
string (UUID)
The conversation/checkpoint thread. For a chat-triggered run, thread_id == chat_id (the same UUID). Use it to read checkpoint state (GET /workflows/state/{thread_id}) and to resume from an interrupt (POST /workflows/resume/{thread_id}). Stable across the whole conversation.
string (UUID) | null
The chat thread the run is attached to. null only when ephemeral: true. Pass it back as the next request’s thread_id to continue the same conversation.

Run modes

POST /workflows/run accepts one of three mutually exclusive definition sources, resolved in this order. Chat-triggered runs use database or ad-hoc mode.

Inputs from chat

The input object is the bridge between the chat turn and run state. It is the workflow’s entry-node state values, and it is also what gets persisted as the human message.

What input becomes

  • Run state. input is passed to the workflow’s entry node as the initial state. Field names must match the workflow’s state_schema.
  • The human message. When ephemeral: false, the input object is written verbatim as a human message ({role: "human", content: <input>}). It is the visible “what the user asked” turn in the chat.
  • The chat title. A new chat is titled with the workflow name, optionally suffixed with up to ~30 characters of the input preview (for example, Research: {'query': 'AI trends'}).

Flat vs nested input

The wire format for input is flat — field name to value:
Flat input (what you send)
When a database-mode run falls back to the deployment’s stored default input (you did not send input), ModuleX normalizes the stored nested form to the flat form automatically:
Stored nested default (normalized for you)
becomes { "query": "AI trends" } before execution. You never send the nested shape — always send flat.

References inside input

Workflow nodes resolve {{node_id.field}} references against run state at execution time, not in the chat input itself. The input you pass seeds the initial state; downstream nodes then read it with references like {{__start__.query}} and chain results with {{plan.output}}. For the full reference and array-spread model, see Variables & references.
A pure {{ref}} value keeps its resolved type; a mixed string (text plus a reference) becomes a templated string; an unresolved reference is left intact in the string rather than erroring. Keep input field names aligned with the workflow’s state_schema or the entry node will not see them.

Continuing a conversation

To run a workflow as the next turn in an existing chat, send that chat’s id as thread_id inside config. The run finds the existing chat, appends your input as a new human message, adds a fresh ai placeholder, and reuses the thread for checkpointing.
Continue an existing chat thread
If the thread_id does not resolve to a chat you can access, the run logs a warning and creates a new chat instead — it does not fail.

Private and ephemeral runs

boolean
default:"false"
When true, the created chat (and its messages) is visible only to the creator, not to the rest of the organization. Applies only when ephemeral is false.
boolean
default:"false"
When true, no chat row, no messages, and no chat_id are created — the run still executes and still streams over run_id, but it is not a chat turn. A fresh thread_id is minted for the checkpointer. Use this for test/preview runs you do not want in chat history. Note: ephemeral runs are still billed (only the admin/system organization is exempt).

Request reference

POST /workflows/run

Headers

Every request authenticates with a bearer token and an organization context header. See Authentication.
Every route in this subsystem requires the owner or admin organization role; the member role is retired. A caller without an admin/owner role gets 403. See Roles & permissions.

Body parameters

The request body is a JSON object. Provide exactly one definition source (workflow_id, workflow, or system_workflow).
string (UUID)
Database mode. Runs the workflow’s live deployment. Mutually exclusive with workflow and system_workflow. Requires a live deployment or returns 400.
object (WorkflowDefinition)
Ad-hoc mode. An inline workflow definition to run without saving. Sets is_ad_hoc: true.
string
System mode. The name of a built-in workflow. Requires both input and config.
object
default:"{}"
Flat state values for the workflow’s entry node. In a non-ephemeral run, also persisted verbatim as the human message. Required for system mode.
object
default:"{}"
Runtime overrides. Recognized keys:
string (UUID)
For ad-hoc runs, the saved workflow to attribute this run to in run history. Does not switch the run to database mode.
boolean
default:"false"
Skip chat-record creation. See Private and ephemeral runs.
boolean
default:"false"
Make the created chat visible only to the creator.
boolean
default:"true"
Echoed back in the response. Token-level streaming was removed; node-event streaming is always available via GET /workflows/listen/{run_id} regardless of this value.

Response (200)

The handler returns immediately — the workflow runs in the background. status is always "running" for a started run; terminal status is observed on the stream, not here.
string
Always "running" on success.
string (UUID)
The execution identity. Stream with GET /workflows/listen/{run_id}.
string (UUID)
The checkpoint thread. Equals chat_id for non-ephemeral runs.
string (UUID) | null
The chat thread. null when ephemeral: true.
boolean
Echoes the request flag.
boolean
Echoes the request flag.
string
The resolved workflow’s name.
string
The resolved workflow’s version.
string
One of "database", "request", or "system:<name>".
number
Wall-clock duration of the synchronous portion (setup), in milliseconds.
object | null
The persisted human message (your input). null when ephemeral: true.
object | null
The AI placeholder message. null when ephemeral: true. Its content starts as a single metadata block ({"type":"metadata","data":{run_id, thread_id, workflow_name, workflow_version, workflow_type:"workflow", timestamp}}); running_status is "running"; workflow carries {schema, input} when the snapshot is small enough to inline.
string
A human-readable hint: "Workflow execution started in background. Use /workflows/listen/{run_id} to track progress."

Worked example

Run a deployed workflow as a chat turn, then stream the result. The auth headers follow AuthenticationAuthorization: Bearer mx_live_… plus X-Organization-ID.
Both SDKs expose this as executions.run(...) and executions.listen(run_id). The continue-a-conversation pattern is identical — pass config: { thread_id: "<chat_id>" } (Python: config={"thread_id": "..."}). For deeper stream consumption, see Streaming & HITL and the SDK ⇄ API parity matrix.

Streaming back

Watch a chat-triggered run with GET /workflows/listen/{run_id}. This is a data-only SSE stream: every frame is a bare data: {json}\n\n with no SSE event: field — the discriminator is the JSON type key. Multiple clients can listen to one run_id at once, and the stream replays buffered history (1-hour TTL) before going live, so a late or reconnecting listener still sees earlier events. For the complete event reference (frame format, history replay, reconnect), see SSE run streaming. The events you will see on a chat-triggered workflow run:
Raw frame trace (run that interrupts, resumes, completes)
The live wire payloads above are what a client actually receives from the executor. They differ from the typed event models the platform also publishes (for example, node_update carries node — a name — not node_id, and has no status or execution_time_ms). Document and parse against the wire shapes shown here, not the typed models. The in-memory store’s “completed” status maps to the durable run status succeeded, so the SSE done event corresponds to a durable status of succeeded, not done.

Pausing and resuming (human-in-the-loop)

If the workflow contains an interrupt node, the run pauses, emits an interrupt frame (the stream stays open), and waits. Resume it with POST /workflows/resume/{thread_id}, passing the run_id and a resume_value that satisfies the interrupt’s resume_schema. Resume reuses the same run_id — re-listen on it to see the rest of the run. No new credit is charged on resume. For the full pause/resume contract, see Human-in-the-loop (HITL) resume.

Cancelling

Cancel an in-flight chat-triggered run with POST /workflows/cancel/{run_id}. Cancellation is graceful (the current node finishes first) and only running or interrupted runs are cancellable. The stream then emits a terminal cancelled frame.

Credit impact

A chat-triggered run is charged exactly one run credit per run, regardless of how many nodes execute. Details:
  • The billing gate runs before any rows are written (reject-before-write). On denial, no chat, messages, run record, or background task are created.
  • The charge is durable and idempotent on run_id. A resume reuses the same run_id, so it does not charge again — one logical run, charged once.
  • Ephemeral runs are still billed. Test and editor runs cost credits; only the admin/system organization is exempt.
  • API-key-triggered runs additionally consume the sync_exec run-rate class; editor/manual JWT runs are not rate-counted at the run layer.
See Credits & metering and Usage gating & limits for the full model.

Errors and status codes

POST /workflows/run returns standard FastAPI {"detail": "…"} envelopes for non-billing errors and a flat DenialEnvelope for billing denials. For the three error-envelope shapes across the platform, see Errors & status codes.
Because the request body is an untyped JSON object, a malformed workflow_schema surfaces as 500 ("Failed to start workflow: …"), not FastAPI’s usual 422.
The billing DenialEnvelope is a flat object — {code, layer, key, current, limit, reason} — with no detail key:
402 credit exhaustion
Errors that arrive on the stream instead of the HTTP response: a 404 on connecting to GET /workflows/listen/{run_id} (the run is unknown or not in your org) is thrown before any frame; an in-stream failure emits a terminal error frame ({type:"error", message:"…"}). See SSE run streaming.

Run a workflow from chat (app)

The end-user view of this same feature inside the ModuleX app.

Run via API

Trigger a workflow programmatically with ephemeral runs and no chat thread.

SSE run streaming

The full event taxonomy, frame format, history replay, and reconnect rules.

Variables & references

How {{node_id.field}} references resolve against run state.

Human-in-the-loop resume

Pause and resume semantics for runs that hit an interrupt node.

Usage gating & limits

The billing admission gate and its 402/403/429 responses.