Workflow Overview
此内容尚不支持你的语言。
A workflow is a directed graph of nodes and edges that defines what an agent actually does when it receives a message. Nodes are instructions — call an LLM, check a condition, invoke a tool, send a reply. Edges connect nodes and determine which path execution takes next.
An agent owns a persona and configuration, but its behavior — what it does step by step for a given conversation — lives entirely in its attached workflow. The same agent persona can be paired with different workflows for different use cases.
Node types
Section titled “Node types”Every node has a type field. The values are lowercase strings — this matters, because the workflow engine compares them as exact string values, not enum keys. There are fifteen node types:
start— the entry point. Every workflow needs exactly one. It takes no configuration (config: {}).llm— calls the agent’s underlying language model. Configured with apromptTemplate(the actual prompt text, templated with Handlebars) and anoutputVar(the name of the context variable the model’s response is stored in). Optional fields let you attach a knowledge base for retrieval-augmented answers, override the system prompt, pick a different model, or stream the output. Use this node for anything that needs language understanding or generation: answering a question, classifying intent, extracting a value from free text.condition— branches based on a boolean expression evaluated against the current execution context (for example,"userAge > 18"). This is the only node type that evaluates an expression directly; it always has exactly two outgoing paths, true and false.router— a multi-way branch driven by a variable’s value rather than an expression. You configure which context variable to inspect and, optionally, a default target for when nothing matches. The actual routing logic lives on the edges leaving the router (see below), not in the node itself. Routers are the standard way to dispatch on a classified intent.response— sends a text reply to the user. Configured with amessageTemplate(Handlebars, supports{{#if}}/{{#each}}blocks) and an optional mood hint. This is the only way a workflow talks back to the user directly.webhook— calls an arbitrary external HTTP endpoint you specify inline (URL, method, headers, body template) without any prior registration step. Useful for one-off integrations you don’t want to register as a full tool.tool— invokes a tool exposed by a registered MCP connector. Configured with the connector to call, the tool name, and a templated arguments object. This is the standard way to call out to your own systems (looking up records, checking availability, fetching data) once you’ve registered an MCP server.action— invokes a registered Custom Action: a plain REST endpoint (no MCP protocol required). Same templated-arguments shape as a tool node, useful when you have an existing REST API and don’t want to stand up an MCP server just to talk to it.end_session— ends the conversation. Optionally requires user confirmation first (rendering a confirmation prompt) and can trigger a post-session evaluation (only when itsrunEvaluationoption istrue; the default is off).await_reply— asks the user a question, ends the turn, and continues from this node’s successor when the next message on that session arrives, with the graph’s variables intact — so a flow that needs an answer mid-way no longer restarts atstarton the user’s reply. Accepts any reply (free_text) or matches it against a list built from a context array (options). Routing is by edge label,answered/unanswered. See Await Reply Node for the full config shape, matching rules and expiry behavior.set_variable— writes one or more context variables without calling a language model: copy or rename a value, set a default, turn text into a real number or boolean, compute a value with an expression, or build a small JSON object from scalar values. No tokens, no credits. See Set Variable Node.switch— a multi-way branch on up to 10 tests, checked in order (for exampleamount > 1000, thentier == "gold"). The first true case routes along the edge labelled with itslabel; when none is true the run follows the edge labelleddefault, which every switch must have. No model call. See Switch Node.approval— pauses the run until a workspace owner approves or rejects it, then continues down theapproved,rejectedorexpirededge. Nothing is held open while it waits. See Human approval: a walkthrough.sub_agent— dispatches to one or more other agents concurrently, each running as its own independent session, and aggregates whatever comes back into an array. The building block for multi-specialist orchestration (a dispatcher agent handing off to a billing specialist and a technical specialist at the same time). See Sub-Agent Node for the full config shape and its result-array gotchas.generate_artifact— asks a model to write a markdown document or an HTML page, or to draw an image, and stores it as an artifact, writing{ artifactId }to itsoutputVar. Behind a platform switch that is off by default. See Generate Artifact Node.knowledge_search— looks up the passages of one of your knowledge bases that best match a query and writes them to itsoutputVaras a list, with no model call. A lookup error fails the node (an empty result does not). See Knowledge Search Node.pdf_export— turns a list of sections held in a variable into a PDF in one of three fixed layouts (brochure,itinerary,slides), stores it as an artifact and writes{ artifactId }to itsoutputVar. No model call, no credits. See PDF Export Node.
Node shape
Section titled “Node shape”Every node in the workflow’s JSON graph has the same envelope:
{ "id": "node_abc123", "type": "llm", "label": "Understand User Query", "config": { "promptTemplate": "Answer the user's question: {{userMessage}}", "outputVar": "llm_response" }, "position": { "x": 100, "y": 50 }}id— a unique string identifier, referenced by edges.type— one of the fourteen lowercase values above.label— a human-readable name shown in logs and in the event stream your client receives while a session runs.config— the type-specific configuration described above, plus the shared fields below.position— optional{x, y}coordinates, used only for visual layout in a graph editor.
Config fields every node type accepts
Section titled “Config fields every node type accepts”Most of config is specific to the node’s type, but one field is shared by every node type:
| Field | Type | Default | Description |
|---|---|---|---|
maxVisits | Int | 1 | How many times this node may run within a single turn. Leave it unset unless a back-edge should genuinely re-run the node. See Loops and re-entry. |
One more shared field applies only to the node types a mock-mode test mocks (tool, action, webhook, llm, sub_agent, pdf_export): mockResponse, any JSON value up to 16 KB that the node returns in a mock-mode draft test or regression replay instead of calling out. A live run ignores it, and saving it on any other type is refused. See Testing a Workflow Safely.
Edge shape
Section titled “Edge shape”{ "id": "edge_def456", "source": "node_abc123", "target": "node_xyz789", "label": "check_availability"}An edge is { id, source, target, label? }. The label field has a different meaning depending on the source node’s type, which is worth internalizing carefully:
- Edges out of a
routernode:labelis matched, by plain string equality, against the current value of the variable the router is configured to inspect. Whichever edge’s label matches wins. - Edges out of a
conditionnode: routing is driven entirely by the node’s own boolean expression, evaluated totrueorfalse. The two outgoing edges must be labeled exactly the lowercase strings'true'and'false'— nothing else. If a label is missing, capitalized, or misspelled, that branch will not fire, and the workflow will now fail with a clear error at execution time rather than doing nothing silently. - Edges out of a
switchnode: one edge per case, labeled exactly with the case’slabel, plus exactly one edge labeled'default'for when no case is true. Only the edge of the case that won (ordefault) is followed. Publishing refuses a switch with a missing, repeated or extra label. - Edges out of an
await_replynode:labelmust be exactly'answered'(required on everyawait_replynode) or'unanswered'(required, and only reachable, whenmatchModeis'options'). The run continues down whichever one matches once the user’s reply arrives. - Edges out of any other node type (
start,llm,response,webhook,tool,action,sub_agent,set_variable,pdf_export) — an edge with nolabelat all is followed unconditionally. These nodes typically have a single outgoing edge. - Edges labeled
'success'and'failure', out of atool,action,webhook,llm,sub_agent,set_variableorpdf_exportnode — opt-in failure routing: the node followssuccesswhen it completes andfailurewhen it fails, instead of failing the whole run. See Failure routing. - An edge labeled
'loop_exhausted', out of any node type at all — an escape route taken only when that node has used up itsmaxVisitsfor the turn and the graph tries to reach it again. It is never followed on an ordinary successful run of the node, so it can sit alongside that node’s normal outgoing edges. See Loops and re-entry.
Failure routing: success and failure edges
Section titled “Failure routing: success and failure edges”Changed 2026-10-15 (live): this used to work on tool and action nodes only. It now works on six node types: tool, action, webhook, llm, sub_agent and set_variable. pdf_export supports it too.
By default, a node that fails stops the whole run: the trace row is FAILED, a NodeFailedEvent is published and the user gets the standard failure reply (or the workflow’s own failureResponse text when one is set). If the workflow’s notifyOnFailure setting is OWNERS, the workspace owners also get an in-app notice (at most one per workflow per hour). A failure handled by a failure edge does neither. To keep the conversation going instead, give the node one outgoing edge labeled success and one labeled failure:
[ { "id": "e1", "source": "lookup_order", "target": "reply_status", "label": "success" }, { "id": "e2", "source": "lookup_order", "target": "reply_sorry", "label": "failure" }]- Opt-in by label only. A node with neither label behaves exactly as before: every outgoing edge is followed, and a failure fails the run. Existing graphs do not change.
- One edge, not a fan-out. Once a node has either label, it follows
successwhen it completes andfailurewhen it fails, and never its other outgoing edges (aloop_exhaustededge still works as usual). failureneedssuccess. Publishing refuses a node with afailureedge and nosuccessedge. Asuccessedge on its own is allowed, but then a failure still fails the run (theSUCCESS_WITHOUT_FAILUREdiagnostic points this out).- What a handled failure looks like. The node’s trace row is
FAILED_HANDLED(notFAILED), the run itself still completes, and the error message (with recognizable secrets redacted) is stored in the_lastNodeErrorcontext variable, so a node on the failure branch can use it, for example{{_lastNodeError}}in anllmprompt. The failed node’s own output variable is not written. - Only the node’s own error routes. What takes the
failureedge is an error raised while the node itself runs: a failed or timed-out request, a malformed template, a refused URL, a model that is not allowed, an unset variable inset_variable. These never take it: a routing error after the node finished, the run’s execution budget, the node’smaxVisitslimit (that is whatloop_exhaustedis for), a newer message interrupting the turn, and a failure to save anawait_replypause.
What to show the end user. _lastNodeError is internal detail: it can name hosts, ids, HTTP status lines and the other service’s own message, so never put it in a reply. Two more context variables are written next to it: _lastNodeErrorCode, a stable code to branch on with a condition or switch (for example _lastNodeErrorCode == "TIMEOUT"), and _lastNodeErrorPublic, a fixed neutral sentence for that code that is safe to send, for example {{_lastNodeErrorPublic}} in a response node. The code is worked out from the error message only, and anything that is not recognised is UNKNOWN; the sentence never contains any part of the original message.
_lastNodeErrorCode | _lastNodeErrorPublic | Typical cause |
|---|---|---|
TIMEOUT | The service took too long to respond. | The request timed out. |
UNREACHABLE | The service could not be reached. | Connection refused or reset, the host name did not resolve, a network failure. |
RATE_LIMITED | The service is busy right now. | HTTP 429 or a rate-limit message. |
REJECTED | The service did not accept the request. | Any other HTTP 4xx answer. |
UPSTREAM_ERROR | The service had a problem handling the request. | An HTTP 5xx answer, or the model provider was overloaded. |
INVALID_DATA | The information could not be processed. | A reply that was not valid JSON, an llm node’s structured reply that did not match its outputSchema, MCP schema drift, an invalid expression. |
UNKNOWN | Something went wrong with that step. | Anything else. |
What counts as a failure, per node type:
| Node type | Takes failure when |
|---|---|
tool | The MCP call fails or times out, the connection fails, the tool’s schema has drifted, or argsTemplate is malformed. |
action | The HTTP call fails or times out, the response cannot be parsed, or argsTemplate is malformed. |
webhook | The request fails, times out or is refused (for example a URL that resolves to a private address). With async: true, only a failure before the request is sent. |
llm | No model answered (after the platform’s own provider fallback gave up), the model is not allowed for your workspace or API key, the modelOverride is not in the catalog, or the image checks refuse the node. |
sub_agent | A configuration error only: no targets, or more than 10. A target that fails or times out is reported in resultVar with status: "error" or "timeout", and the node still takes success. |
set_variable | An assignment fails: a referenced variable is not set and has no default, a coercion fails (for example coerce: "number" on text), or a template or JSON value does not render. |
generate_artifact | The platform switch is off (DISABLED), a limit or the spend cap is reached, the prompt is refused, or the model or storage fails. See Failures. |
pdf_export | The sections variable is not set, malformed or over a limit; another document is rendering on the same server (busy); or rendering or storing the PDF fails. See Failures. |
Other node types. On a router, success and failure are ordinary route values matched against the router’s variable, with no failure meaning. A condition or switch follows only its own branch labels, and a response or end_session follows every outgoing edge whatever its label. The FAILURE_EDGE_ON_UNROUTABLE_NODE diagnostic flags those four cases. await_reply and approval route by their own outcome labels.
Retrying a failed call: retry
Section titled “Retrying a failed call: retry”Live in production since 2026-10-24. A tool, action or webhook node can carry an optional retry policy in its config. Without one, the node keeps its built-in behaviour: a tool retries a connection failure up to 3 attempts, an action up to 3 attempts with no wait, a webhook never retries. With one, the policy replaces the built-in retry, so { "maxAttempts": 1 } turns retrying off.
{ "retry": { "maxAttempts": 3, "on": ["UNREACHABLE", "RATE_LIMITED", "TIMEOUT"], "idempotent": true }}| Field | Type | Default | Description |
|---|---|---|---|
maxAttempts | Int | required | Total attempts including the first, 1 to 4. |
backoff | String | "exponential" | "fixed" waits delayMs every time; "exponential" doubles it. |
delayMs | Int | 500 | First wait in ms, 100 to 5000. |
maxDelayMs | Int | 5000 | Cap on any one wait in ms, from delayMs to 5000. |
jitter | Boolean | true | Spreads each wait by up to 20 % either way. |
on | [String] | ["UNREACHABLE", "RATE_LIMITED"] | Failure classes to retry: any of TIMEOUT, UNREACHABLE, RATE_LIMITED, UPSTREAM_ERROR (the same codes as _lastNodeErrorCode). |
idempotent | Boolean | false | Your statement that sending the request twice is harmless. |
deadlineMs | Int | none | Time for all attempts and waits together, 1000 to 120000. The run’s own deadline always applies too. |
Rules worth knowing:
- Only safe resends. A failure is retried only when its class is in
onand the request never reached the service (connection refused, DNS failure, host unreachable, or for atoola failure while connecting), or the service answered429, or the request is idempotent (idempotent: true, aGET, or anIdempotency-Keyheader). So aPOSTthat timed out or got a 5xx is not resent unless you mark it idempotent; theRETRY_NOT_IDEMPOTENTdiagnostic warns about this.REJECTED(a 4xx),INVALID_DATAandUNKNOWNare never retried. - Waits are short. The total wait per node is capped (10 seconds by default). A
Retry-Afteron a 429 or 503 is honoured; if it asks for longer thanmaxDelayMs, the node gives up instead. Each attempt uses the node’s owntimeoutMs. - Saving refuses
retryon any other node type, on awebhookwithasync: true, on anactionwithfetchAsBase64Var, and any unknown field insideretry. - After the last attempt the node fails as usual, so a
failureedge (above) handles it. An interrupt stops retrying. Retries do not count toward the run’s node budget. - In the trace, the row of a node that retried or failed carries
attempts(one entry per failed attempt:n,errorClass,httpStatuswhen there was one,waitMs),retryOutcome(succeeded_after_retry,exhausted,not_retryable,not_idempotent,interrupted,deadline,wait_cap,retry_after_too_longormock) anderrorClass. No error text or response body. - In a mock-mode test a mocked failure is classified but never retried (
retryOutcome: "mock").
Worked example: a router + tool workflow
Section titled “Worked example: a router + tool workflow”This example handles a generic order-status assistant: it classifies the user’s message, and if they’re asking about an order, looks it up via a tool call; otherwise it gives a generic help response.
{ "nodes": [ { "id": "start", "type": "start", "label": "Start", "config": {}, "position": { "x": 0, "y": 0 } }, { "id": "classify", "type": "llm", "label": "Classify Intent", "config": { "outputVar": "intent", "promptTemplate": "Classify this message: \"{{userMessage}}\". Respond with ONLY one word: order_status, or general." }, "position": { "x": 0, "y": 100 } }, { "id": "dispatch", "type": "router", "label": "Intent Dispatcher", "config": { "variable": "intent", "defaultTarget": "respond_general" }, "position": { "x": 0, "y": 180 } }, { "id": "lookup_order", "type": "tool", "label": "Look Up Order Status", "config": { "toolName": "get_order_status", "connectorId": 12, "outputVar": "order_result", "argsTemplate": "{\"orderQuery\": \"{{userMessage}}\"}" }, "position": { "x": 250, "y": 250 } }, { "id": "respond_order", "type": "response", "label": "Respond with Order Status", "config": { "messageTemplate": "Here's your order status: {{order_result}}", "mood": "helpful" }, "position": { "x": 350, "y": 250 } }, { "id": "respond_general", "type": "response", "label": "Respond to General Query", "config": { "messageTemplate": "I can help you check an order's status. What would you like to know?", "mood": "neutral" }, "position": { "x": 350, "y": 50 } }, { "id": "end", "type": "end_session", "label": "End", "config": {}, "position": { "x": 450, "y": 150 } } ], "edges": [ { "id": "e1", "source": "start", "target": "classify" }, { "id": "e2", "source": "classify", "target": "dispatch" }, { "id": "e3", "source": "dispatch", "target": "lookup_order", "label": "order_status" }, { "id": "e4", "source": "dispatch", "target": "respond_general", "label": "general" }, { "id": "e5", "source": "lookup_order", "target": "respond_order" }, { "id": "e6", "source": "respond_order", "target": "end" }, { "id": "e7", "source": "respond_general", "target": "end" } ]}The classifier’s output (intent) feeds the router; the router’s outgoing edges are labeled with the exact values the classifier can produce (order_status, general). If the classifier ever outputs a word the router has no matching edge for, it falls back to defaultTarget.
Publishing
Section titled “Publishing”A workflow isn’t live until it’s explicitly published. The full authoring flow has three steps:
- Create the workflow (name and description only).
- Update it with your
nodesandedgesJSON. - Publish it.
A workflow that’s been updated but never published will not run for any session — there’s no error, it simply never executes. Always confirm a workflow is published before wiring it to an agent and testing it end to end.
Every publish that changes the published graph is kept as a numbered version. You can list them, compare one with your draft before publishing, and roll back to an earlier one without touching the draft: see Version History and Rollback.
What publishing checks
Section titled “What publishing checks”Publishing validates the graph’s shape and rejects it with a clear error if any of these are true:
- No
STARTnode, or more than one. - An edge references a
sourceortargetnode ID that doesn’t exist. - Two nodes share the same
id. - A node has no path from
START— it’s disconnected from the rest of the graph. - Any node’s required config fields are missing (e.g. an
llmnode with nopromptTemplate). - The graph is larger than the size cap — more than 200 nodes or 400 edges.
- Any node’s
maxVisitsis not a positive integer, or is above the ceiling of 100 — see Loops and re-entry. - A
tool,action,webhook,llm,sub_agent,set_variableorpdf_exportnode has an edge labeledfailurebut none labeledsuccess— see Failure routing.
To see likely problems that publishing does not reject (a router with no fallback, a branch that can never be taken, an extraction option the node will ignore), run workflow diagnostics on the draft first. They never block publishing.
A workflow with an intentional loop-back is fine. A router node routing back to an earlier step (e.g. a retry pattern) is a supported design, not something publishing rejects — cycles aren’t checked, only genuinely disconnected or duplicate-ID nodes are. Whether that back-edge actually re-runs the earlier node is a separate question, answered by that node’s maxVisits; see the next section.
Loops and re-entry
Section titled “Loops and re-entry”Added 2026-09-22. A back-edge in your graph can now genuinely re-run the node it points at, bounded by a number you set on that node.
Before this, a run visited each node at most once. A back-edge was accepted at publish time and then silently did nothing at runtime: the engine dropped the second arrival with no error, no log line and no trace entry, and the run still reported COMPLETED. If you have drawn a retry loop and wondered why it never retried, that is why.
maxVisits
Section titled “maxVisits”maxVisits is a config field on the node itself — not on the edge, and not a top-level sibling of id/type/label. It is available on every node type.
{ "id": "n_submit_claim", "type": "action", "label": "Submit Claim To Partner", "config": { "maxVisits": 3, "customActionId": 41, "outputVar": "submitResult" }, "position": { "x": 420, "y": 160 }}- Defaults to
1when unset, which is exactly the old behavior — every graph published before this existed runs identically. - Counts dispatches within one run, so
"maxVisits": 3means the node may execute at most three times in that turn. - Capped at 100, and validated when the workflow is saved (drafts included) as well as at publish. A zero, a negative, a fraction or a value above the ceiling is rejected by the mutation, not discovered mid-conversation.
- Per run, and a run is one turn. The counter resets when a new turn starts, and it resets when an
await_replypause resumes — a resumed turn is a new run with fresh counters. An “ask, validate, ask again” loop that spans anawait_replyis therefore bounded by the person replying, not by this number.
The field is exposed through nodeConfigSchema for every node type, so a form-generating editor picks it up automatically. The dashboard’s own workflow builder does not yet render an input for it — author it in the nodes JSON you pass to updateWorkflow.
What happens when the budget runs out
Section titled “What happens when the budget runs out”When the graph reaches a node that has already used up its maxVisits, exactly one of three things happens. In all three the engine writes a VISIT_LIMIT_REACHED entry into the run’s nodeTrace carrying visitLimit and visitCount, so the stop is always visible:
| Situation | Outcome | Run status |
|---|---|---|
The node has a 'loop_exhausted' outgoing edge | The run follows that edge and carries on. This is the behavior you want for a retry that gives up gracefully. | unaffected |
No escape edge, and maxVisits was set explicitly on the node | The run fails with WorkflowNodeVisitLimitExceededError, and the user is told the turn failed. | FAILED |
No escape edge, and maxVisits was left unset (the default 1) | That branch stops there; the rest of the graph carries on. Same as the old behavior, but now recorded. | unchanged |
The asymmetry in the last two rows is deliberate. Writing "maxVisits": 5 is a statement that five iterations should be enough, so a sixth arrival is a real authoring bug and is surfaced loudly. A graph that never opted into looping keeps working exactly as it did — an accidental back-edge someone drew years ago does not start failing runs today. The difference is that it now leaves a trace entry instead of nothing at all.
Two details worth knowing before you rely on the escape edge:
- The engine follows a node’s
'loop_exhausted'edge at most once per run, and records at most one refusal entry per node. Without that bound, an escape edge pointing back into its own loop would re-queue forever (a refused node spends no execution budget), and a graph where many nodes converge on one exhausted node would write one trace entry per incoming edge. - After that one escape, coming back to the same node again is treated as the second row of the table: if the node declared
maxVisits, the run fails. “You gave the graph a way out, it took it, and execution came straight back here” is an authoring defect worth seeing.
Worked example: bounded retry with a graceful give-up
Section titled “Worked example: bounded retry with a graceful give-up”A claims assistant submits a claim to a partner API that is occasionally flaky. Try three times, then apologize and hand off rather than looping forever or dying with a generic error. All ids and values below are illustrative.
{ "nodes": [ { "id": "n_start", "type": "start", "label": "Start", "config": {} }, { "id": "n_submit", "type": "action", "label": "Submit Claim", "config": { "maxVisits": 3, "customActionId": 41, "outputVar": "submitResult", "timeoutMs": 10000 } }, { "id": "n_wait_note", "type": "response", "label": "Tell User We Are Retrying", "config": { "maxVisits": 3, "messageTemplate": "That didn't go through — trying once more." } }, { "id": "n_confirm", "type": "response", "label": "Confirm Submission", "config": { "messageTemplate": "Done. Your claim reference is {{submitResult.reference}}." } }, { "id": "n_give_up", "type": "response", "label": "Hand Off To A Human", "config": { "messageTemplate": "I couldn't reach our claims system after a few tries. I've flagged this for a colleague, who will follow up by email today." } } ], "edges": [ { "id": "e1", "source": "n_start", "target": "n_submit" }, { "id": "e2", "source": "n_submit", "target": "n_confirm", "label": "success" }, { "id": "e3", "source": "n_submit", "target": "n_wait_note", "label": "failure" }, { "id": "e4", "source": "n_wait_note", "target": "n_submit" }, { "id": "e5", "source": "n_submit", "target": "n_give_up", "label": "loop_exhausted" } ]}What the run actually does when the partner API is down:
n_submitruns (visit 1), throws, and takes itsfailureedge ton_wait_note.n_wait_noteruns (visit 1), sends the retry notice, and routes back ton_submit.n_submitruns (visit 2) and fails;n_wait_noteruns (visit 2); back ton_submit.n_submitruns (visit 3) — its last permitted visit — and fails;n_wait_noteruns (visit 3); back ton_submitonce more.- The engine refuses a fourth dispatch of
n_submit. It has a'loop_exhausted'edge, so the engine writes aVISIT_LIMIT_REACHEDentry (visitLimit: 3,visitCount: 3) and routes ton_give_up. n_give_upsends the hand-off message. The run completes.
That is 8 node executions in total (n_start, three of n_submit, three of n_wait_note, n_give_up) out of the run’s budget of 250.
Four things make this graph well-behaved, and are worth copying:
- Every node inside the cycle raises
maxVisits, not just the one doing the work. This is the easiest thing to get wrong.n_wait_noteis on the loop too, so it is dispatched once per attempt — leave it at the default and the second attempt’sfailureedge reaches a node that has already used its single visit, which silently stops that branch and the retry never completes. Set the bound onn_submitandn_wait_note. - The escape edge is on the node that owns the budget, alongside its
successandfailureedges. It only fires on refusal, so it never competes with them. - The counts line up.
n_submitgets 3 attempts, son_wait_noteneeds 3 too (it speaks after each failure, including the last). If the numbers around a cycle disagree, the smallest one is what actually ends the loop — and it ends it as a silent branch stop rather than at your escape edge. - The give-up path says something useful to the user. Delete
e5and this graph still works, but the fourth arrival fails the run and the user gets the generic “Sorry, I ran into a problem” reply instead.
How this composes with the run budget
Section titled “How this composes with the run budget”Every visit counts once against the run’s total node-execution budget, so the two bounds add rather than multiply — a node with "maxVisits": 3 spends three of the run’s 250 node executions, not three separate allowances. The ceiling on maxVisits (100) is deliberately below the run budget (250) so that a single looping node can never consume the whole run by itself.
The checks run in this order before any node is dispatched: a user-initiated interrupt first, then the run budget, then the node’s visit budget. A refused node never executes, never emits a NODE_EXECUTING event, and never spends run budget.
Graph size and run limits
Section titled “Graph size and run limits”Added 2026-09-21. A workflow graph is bounded at save time, and a single run is bounded at execution time. Both are generous relative to anything anyone has actually built — the largest real graph on the platform is 73 nodes / 76 edges, and the longest run ever recorded took 79 seconds — but they’re worth knowing, because hitting one produces a specific, diagnosable failure rather than a mystery.
| Limit | Default | Enforced | What happens when you exceed it |
|---|---|---|---|
| Nodes per workflow | 200 | updateWorkflow (draft save) and publish | The mutation is rejected: Workflow has N nodes, exceeding the maximum of 200. |
| Edges per workflow | 400 | updateWorkflow (draft save) and publish | The mutation is rejected: Workflow has N edges, exceeding the maximum of 400. |
| Node executions per run | 250 | At runtime, between node dispatches | The run fails with a BUDGET_EXCEEDED entry in its nodeTrace — see below. |
| Wall-clock duration per run | 10 minutes | At runtime, between node dispatches | Same: the run fails with a BUDGET_EXCEEDED entry naming deadline as the limit that was hit. |
maxVisits per node | 100 | updateWorkflow (draft save) and publish | The mutation is rejected: Node "x" has maxVisits 400, exceeding the maximum of 100. |
The size caps apply to a draft save too, not just publish — an oversized graph can’t be parked in a draft and published later.
The runtime budget is checked between node dispatches, never mid-node: a node already in flight always finishes. It therefore bounds a runaway graph, not a single node that hangs — use the per-node timeoutMs on action, webhook and sub_agent nodes for that. Note a sub_agent dispatch runs its child workflow as its own run with its own independent budget, and a resumed await_reply turn is likewise a new run with a fresh budget — neither inherits the parent’s remaining allowance.
When a run exceeds either runtime limit it is persisted as FAILED (there is no separate run status for it) and its nodeTrace carries one extra entry, attributed to the node the engine refused to dispatch:
{ "nodeId": "n_lookup_again", "nodeType": "action", "status": "BUDGET_EXCEEDED", "budgetLimit": "maxNodeExecutions", "budgetNodeExecutions": 250, "budgetElapsedMs": 18422, "error": "Workflow run exceeded its node budget of 250 node executions (stopped before node 'n_lookup_again', 18422ms elapsed). …", "startedAt": "2026-09-21T09:14:03.128Z", "finishedAt": "2026-09-21T09:14:03.128Z"}budgetLimit is "maxNodeExecutions" or "deadline"; budgetNodeExecutions and budgetElapsedMs record where the run had got to. The named node did not run — nothing about it went wrong, the run ran out of budget in front of it. Those three fields appear on BUDGET_EXCEEDED entries only, so existing trace-parsing code is unaffected. See Workflows API Reference for how to read this back.
For a graph with no loops in it you are far more likely to meet the deadline than the node budget: the node cap (250) is deliberately set above the graph size cap (200), so a single full traversal of the largest graph the platform will accept can never trip it. A run that trips the deadline is usually a slow partner API being retried; tighten that node’s own timeoutMs (default 10s, ceiling 30s on action/webhook/tool nodes) rather than leaning on the run-level ceiling.
Once a graph does loop, the node budget becomes the real total bound — every visit spends one execution from the same 250, so a few nodes with a high maxVisits can reach it where a straight-line graph never would. A run that trips it produces BUDGET_EXCEEDED rather than VISIT_LIMIT_REACHED, because the run as a whole ran out before any single node did.
How continuity between turns actually works
Section titled “How continuity between turns actually works”The single most important thing to understand about this engine, and the thing most likely to surprise you if you’re picturing a stateful conversation system: by default, nothing persists between turns except the conversation transcript itself.
Concretely:
- Every inbound message triggers a brand-new run, starting at your workflow’s
startnode — not wherever the previous turn happened to end. - Context variables — whatever an
llm/action/tool/webhooknode wrote viaoutputVar— do not carry over from one run to the next. Each run builds its own context from scratch, seeded only with turn-level facts (userMessage,conversationHistory,sessionId,tenantId, any attachment fields present on this specific turn) — never with anything an earlier run’s nodes computed. - The only thing that genuinely persists automatically is the conversation transcript: the full back-and-forth of user/assistant messages, available to any
llmnode asconversationHistory.
So how does a graph that needs several turns to collect information (asking a few questions across a few messages, say) work at all? Almost entirely by giving an llm node the full transcript and having it figure out, itself, what’s already been covered — not by the engine tracking progress on your behalf. A prompt along the lines of “read the conversation so far and ask whichever of these questions hasn’t been answered yet — never repeat one that has” genuinely works, because the model re-derives “where we are” from scratch, from plain text, every single turn. There is no graph-level memory backing that behavior — it’s entirely a property of what you put in the prompt.
The one real, structural exception: await_reply. This node type is the deliberate alternative to the above — it writes a genuine, persisted “waiting for a reply, and here’s what for” record, and when the next message arrives, execution resumes at that specific node’s answered/unanswered successor, rather than starting over at start. Reach for it when you need a hard guarantee that the very next message is treated as an answer to a specific question, rather than leaving that inference to an LLM reading history. It costs a little more to author (a dedicated node, explicit branch labels) in exchange for that guarantee.
| Fresh run every turn (the default, no special node) | await_reply | |
|---|---|---|
| Is anything about “where we are” stored? | No — nothing at all | Yes — a real, persisted wait record, tenant-scoped |
| How does the graph “know” what’s already been covered? | An llm node re-reads the full conversationHistory and infers it | The engine looks up the exact node that’s waiting and resumes there directly |
| What happens if the reply doesn’t match what was expected? | Whatever that message’s own content naturally routes to — there’s no “expected” anything | A defined unanswered edge, matched via a deliberate tiered comparison (exact → normalized → unique-token match) |
| Cost of getting it wrong | An llm node might mis-infer progress on an unusual message | None if authored correctly — the match is structural, not inferred |
A worked example, turn by turn
Section titled “A worked example, turn by turn”A workflow gate-keeping a support ticket on two required facts (an order number and a reason) before escalating it, with no await_reply — the default, fresh-run-every-turn behavior:
sequenceDiagram participant U as User participant W as Wetel Workflow Engine participant H as Conversation History (the only real "memory")
U->>W: Turn 1: "My order arrived broken" Note over W: A NEW run starts at start, with zero memory of any earlier turn.<br/>A completeness check finds no order number anywhere in H -> asks for it. W->>H: (turn 1 + this reply get appended here automatically) W->>U: "Sorry to hear that — what's your order number?" Note over W: This run ends HERE. Nothing is paused. Nothing is held in memory by the engine itself.
U->>W: Turn 2: "ORD-48213" Note over W: A BRAND NEW, unrelated run starts at start.<br/>It has no idea a question was just asked - it only knows what H contains.<br/>An llm node reads the FULL H (including turn 1) and finds both the reason and the order number now present -> escalates. W->>U: "Thanks — I've escalated ORD-48213 to our support team."Notice what’s easy to miss: the engine never “knew” it was waiting for an order number between turns 1 and 2. It only worked because the node reading conversationHistory on turn 2 happened to be instructed to look for one. Swap in await_reply for the same flow and the mechanics change entirely: the engine itself would record “waiting for an order number” after turn 1, and turn 2 would resume at that exact node — no inference required, at the cost of authoring the wait explicitly.
Restated plainly
Section titled “Restated plainly”- Does a run “come back” to where it left off? No, never — unless you used
await_replyfor that exact node. Every other turn starts fully fresh. - Can I rely on a context variable from turn N still being there on turn N+1? No. Re-derive it from
conversationHistoryin your prompt, or useawait_replyif you specifically need to guarantee the next message maps to a particular question. - Is there a general “durable variable that survives across turns” mechanism, outside of these two? Not today.
conversationHistoryandawait_reply’s own resume snapshot are the only two things that cross a run boundary.
How this compares to state-machine libraries (XState, statecharts)
Section titled “How this compares to state-machine libraries (XState, statecharts)”If you’ve used a statechart library like XState before, the node/edge model above will look familiar — nodes are conceptually close to states, edges are transitions, and a router node’s outgoing edges are close to guarded transitions keyed on a value. It’s worth being explicit about where the resemblance ends, so you know what to expect.
What Wetel’s model gives you that a general-purpose FSM library doesn’t:
- Domain-specific node types are built in.
llm,tool,action, andwebhooknodes already know how to call a language model, invoke an MCP tool, hit a custom REST action, or call an arbitrary webhook — including templating, timeouts, and error handling for each. In a general statechart library, every one of those would be application code you write yourself inside an action or invoked service. - Concurrent dispatch to independent sub-processes is also built in, via the
sub_agentnode — it fans out to one or more other agents at once, each running as its own independent session, and best-effort-aggregates whatever comes back. This is closer to actor-model concurrency than a statechart’s parallel regions (see below). - It’s hosted, and your conversation history persists server-side without you managing it. Be precise about what that does and doesn’t include, though — see How continuity between turns actually works just above: a workflow’s position in the graph and its accumulated context variables do not persist automatically (every turn re-runs from
start, by default with a fresh context) — only the message transcript does, plus whateverawait_replyexplicitly opts into. A client-side state machine library typically expects you to own persistence yourself if state needs to survive a page reload or a backend restart; here the transcript is free, but graph-position/variable persistence is something you either don’t need (because your prompt reads history) or explicitly ask for (viaawait_reply), not something that happens for you by default. - No client library or runtime dependency. You author a workflow as JSON via the API; nothing runs in your own process. This cuts both ways — see below.
What a general-purpose library like XState gives you that Wetel’s model doesn’t:
- Statechart-style hierarchical/parallel states within a single graph. Wetel’s graph itself is still a flat set of nodes — there’s no nested sub-state or concurrent regions within one workflow’s own node/edge structure. Complex in-graph branching still has to be flattened directly (routers and conditions). A
sub_agentnode gives you concurrency and delegation across separate agents/sessions, which covers a lot of the same real-world need, but it’s a different mechanism (dispatch to independent sessions) than a statechart’s nested/parallel states within one machine. - Static validation and simulation tooling. Libraries like XState (paired with tools like Stately Studio) can validate a machine’s shape and let you simulate transitions without ever running the real system. Wetel validates a workflow’s shape at publish time (see the checks listed above) but has no equivalent offline simulation — testing a workflow currently means running a real session against it.
- Portability outside the platform. An XState machine is just JavaScript/JSON you can run anywhere. A Wetel workflow only executes inside Wetel’s own runtime — it’s not something you can lift out and run in a different context.
In short: Wetel’s workflow graph is purpose-built for one thing — defining a conversational agent’s turn-by-turn logic against Wetel’s own AI/tool primitives — rather than being a general-purpose state-machine runtime. If you need a state machine for something unrelated to a Wetel conversation (client-side UI state, an unrelated backend process), a dedicated library like XState is likely still the better tool for that job; the two aren’t mutually exclusive within the same product.
Next steps
Section titled “Next steps”- See the end-to-end example: Triage a support ticket end to end uses
await_reply,llm,set_variable,switch,approval,responseandend_sessionin one tested graph. - See Version History and Rollback to compare, roll back or restore published versions.
- See Best Practices for templating gotchas and LLM-reliability patterns to apply when authoring nodes.
- See MCP Connectors for registering the tools your
toolnodes call. - See Events & Subscriptions for how to observe a workflow’s execution (node-by-node) from your client.
- See Troubleshooting if a branch isn’t firing or a tool call is failing silently.