Skip to content

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.

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 a promptTemplate (the actual prompt text, templated with Handlebars) and an outputVar (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 a messageTemplate (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 at start on 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.

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.

Most of config is specific to the node’s type, but one field is shared by all eleven:

FieldTypeDefaultDescription
maxVisitsInt1How 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.
{
"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 router node: label is 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 condition node: routing is driven entirely by the node’s own boolean expression, evaluated to true or false. 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_reply node: label must be exactly 'answered' (required on every await_reply node) or 'unanswered' (required, and only reachable, when matchMode is '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 no label at 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 its maxVisits for 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.

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.

A workflow isn’t live until it’s explicitly published. The full authoring flow has three steps:

  1. Create the workflow (name and description only).
  2. Update it with your nodes and edges JSON.
  3. 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.

Publishing validates the graph’s shape and rejects it with a clear error if any of these are true:

  • No START node, or more than one.
  • An edge references a source or target node 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 llm node with no promptTemplate).
  • The graph is larger than the size cap — more than 200 nodes or 400 edges.
  • Any node’s maxVisits is 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.

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 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 1 when unset, which is exactly the old behavior — every graph published before this existed runs identically.
  • Counts dispatches within one run, so "maxVisits": 3 means 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_reply pause resumes — a resumed turn is a new run with fresh counters. An “ask, validate, ask again” loop that spans an await_reply is 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.

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:

SituationOutcomeRun status
The node has a 'loop_exhausted' outgoing edgeThe 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 nodeThe 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:

  1. n_submit runs (visit 1), throws, and takes its failure edge to n_wait_note.
  2. n_wait_note runs (visit 1), sends the retry notice, and routes back to n_submit.
  3. n_submit runs (visit 2) and fails; n_wait_note runs (visit 2); back to n_submit.
  4. n_submit runs (visit 3) — its last permitted visit — and fails; n_wait_note runs (visit 3); back to n_submit once more.
  5. The engine refuses a fourth dispatch of n_submit. It has a 'loop_exhausted' edge, so the engine writes a VISIT_LIMIT_REACHED entry (visitLimit: 3, visitCount: 3) and routes to n_give_up.
  6. n_give_up sends 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_note is on the loop too, so it is dispatched once per attempt — leave it at the default and the second attempt’s failure edge reaches a node that has already used its single visit, which silently stops that branch and the retry never completes. Set the bound on n_submit and n_wait_note.
  • The escape edge is on the node that owns the budget, alongside its success and failure edges. It only fires on refusal, so it never competes with them.
  • The counts line up. n_submit gets 3 attempts, so n_wait_note needs 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 e5 and 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.

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.

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.

LimitDefaultEnforcedWhat happens when you exceed it
Nodes per workflow200updateWorkflow (draft save) and publishThe mutation is rejected: Workflow has N nodes, exceeding the maximum of 200.
Edges per workflow400updateWorkflow (draft save) and publishThe mutation is rejected: Workflow has N edges, exceeding the maximum of 400.
Node executions per run250At runtime, between node dispatchesThe run fails with a BUDGET_EXCEEDED entry in its nodeTrace — see below.
Wall-clock duration per run10 minutesAt runtime, between node dispatchesSame: the run fails with a BUDGET_EXCEEDED entry naming deadline as the limit that was hit.
maxVisits per node100updateWorkflow (draft save) and publishThe 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 start node — not wherever the previous turn happened to end.
  • Context variables — whatever an llm/action/tool/webhook node wrote via outputVar — 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 llm node as conversationHistory.

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 allYes — 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 itThe 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” anythingA defined unanswered edge, matched via a deliberate tiered comparison (exact → normalized → unique-token match)
Cost of getting it wrongAn llm node might mis-infer progress on an unusual messageNone if authored correctly — the match is structural, not inferred

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.

  • Does a run “come back” to where it left off? No, never — unless you used await_reply for 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 conversationHistory in your prompt, or use await_reply if 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. conversationHistory and await_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, and webhook nodes 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_agent node — 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 whatever await_reply explicitly 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 (via await_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_agent node 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.

  • See Best Practices for templating gotchas and LLM-reliability patterns to apply when authoring nodes.
  • See MCP Connectors for registering the tools your tool nodes 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.