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 eleven 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.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.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.
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 eleven 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 all eleven:
| 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. |
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 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) — an edge with nolabelat all is followed unconditionally. These nodes typically have a single outgoing edge. - 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.
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.
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 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 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.