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 onePOST /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.
- Database (recommended)
- Ad-hoc (inline)
- System
Pass
workflow_id. The run loads the workflow’s live deployment snapshot. Your request input overrides the deployment default input; your request config merges over the deployment config. If the workflow has no live deployment, the run returns 400 — deploy it first via Deploy & versions.Database mode body
Inputs from chat
Theinput 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.
inputis passed to the workflow’s entry node as the initial state. Field names must match the workflow’sstate_schema. - The human message. When
ephemeral: false, theinputobject is written verbatim as ahumanmessage ({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 forinput is flat — field name to value:
Flat input (what you send)
input), ModuleX normalizes the stored nested form to the flat form automatically:
Stored nested default (normalized for you)
{ "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.
Continuing a conversation
To run a workflow as the next turn in an existing chat, send that chat’s id asthread_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
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 Authentication —Authorization: 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 withGET /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)
Pausing and resuming (human-in-the-loop)
If the workflow contains an interrupt node, the run pauses, emits aninterrupt 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 withPOST /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 samerun_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_execrun-rate class; editor/manual JWT runs are not rate-counted at the run layer.
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.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.
Related
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.