Sub-Agent Node
此内容尚不支持你的语言。
The sub_agent node dispatches the current turn to one or more other Wetel agents, each running as its own independent Session — its own turn history, its own cost tracking, its own tenant-scoped resources — rather than continuing in the calling (“master”) session. This is the building block for multi-specialist orchestration: a router/dispatcher agent that hands off to a billing specialist, a technical specialist, and a scheduling specialist, then combines what comes back.
Every entry in targets dispatches concurrently, not sequentially — this node fans out to all of them at once and best-effort-aggregates whatever comes back. One target failing or timing out never blocks or cancels the others.
See Workflows Overview for how this node type fits into the broader node/edge model.
Sub-agent or router branches?
Section titled “Sub-agent or router branches?”Before reaching for this node, ask whether the paths really need to be separate agents. A router (or switch) that sends the conversation down different branches of one workflow is simpler and cheaper when the paths share the same facts.
| Branches in one workflow | sub_agent | |
|---|---|---|
Facts from the incoming message (channel.raw, attachment, the conversation) | Available in every node, no copying | Only what you pass: named values with inputs, or text in contextTemplate |
| Extra sessions, model calls and latency | None | One session per target, and a model call to read the opening message |
| Multi-turn conversation with the customer | Yes (the workflow can wait for replies with await_reply) | No: a sub-agent is a one-shot run that cannot pause for a reply |
| A different persona, knowledge base or model per path | No: it is one agent | Yes: each target is its own agent |
| Usage, cost and sessions reported per specialist | No | Yes: each target has its own session and cost tracking |
| Tested and published on its own | No | Yes |
| Several paths at the same time | No: a router follows one branch | Yes: every target runs concurrently |
Use branches when the paths are parts of one job, such as “route the message to a ticket path or a lookup path”, and an action on either path needs ids or contact details from the incoming message (a sub-agent can also receive those, one named value at a time, with inputs, but branches need no copying at all). Use sub-agents when each path is a specialist that should be its own agent: a different persona or knowledge base, its own cost line, or concurrent work on one question.
Config fields
Section titled “Config fields”SubAgentNodeConfig:
| Field | Type | Required | Description |
|---|---|---|---|
targets | array | Yes | One to ten dispatch targets — see below. Must be non-empty; more than 10 is rejected when you save or publish the workflow (and again at run time), to bound how many agents one turn can fan out to. |
targets[].agentId | number | Yes | The agent to dispatch to. Must belong to the same tenant as the calling agent — checked at execution time (not just when the workflow was saved), since an agent can be deleted or reassigned after the workflow was published. |
targets[].contextTemplate | string | Yes | A Handlebars template, rendered against the master session’s current execution context, that becomes the sub-agent’s opening message. This is intentionally author-templated, not an automatic full-history forward — give each target enough context to do its job, not the master’s entire conversation. |
targets[].inputs | object | No | Named values to put in the sub-agent’s context before it starts, as { "variableName": "<Handlebars template>" }, rendered against the master’s context. Text only. At most 20 per target, names follow the set_variable rules. See Inputs and outputs. |
targets[].outputs | string[] | No | Names of variables in the sub-agent’s final context to return as values on this target’s result entry. At most 20 per target. See Inputs and outputs. |
resultVar | string | Yes | The context variable the aggregated results are stored in — always an array, see below, regardless of how many targets you configure. |
timeoutMs | number | No | Per-target timeout in milliseconds. Defaults to 30000 (30 seconds), clamped to a hard ceiling of 120000 (2 minutes). A value of 0, a negative number or anything that is not a number is treated as unset and the default applies (it never means “no timeout”); a value below 1000 is raised to 1000. Applies independently per target — one target timing out doesn’t affect its siblings’ clocks. |
The resultVar shape
Section titled “The resultVar shape”ctx[resultVar] is always an array, in the same order as targets (not completion order), even for a single target:
Array<{ agentId: number; status: "ok" | "timeout" | "error" | "awaiting_approval"; text?: string; // the reply when status === 'ok'; the last reply already shown when status === 'awaiting_approval' error?: string; // present only when status is 'timeout' or 'error' approvalId?: number; // present only when status === 'awaiting_approval' values?: Record<string, unknown>; // only when the target declared `outputs` and status === 'ok' missingValues?: string[]; // requested outputs that were absent or over a size cap}>;Four statuses exist. ok, timeout and error are as before. awaiting_approval is a fourth status, added 2026-11-05 (in production since that date). It is additive: a graph that only checks status == "ok" behaves exactly as it did, because an awaiting_approval entry is simply not ok. See A target that waits for an owner approval.
A sub-agent dispatch is never retried — a target that timed out may already have run its own side-effecting TOOL/ACTION calls, and blindly re-running it is not safe.
A target that waits for an owner approval
Section titled “A target that waits for an owner approval”Added 2026-11-05. In production since 2026-11-05. The workspace app screens that show the pattern (the hire panel, the request-changes button) are in the Vercel app, which still points at staging.
A target’s workflow can contain an approval node, for example “draft a proposal, then wait for the owner”. When the target’s run stops at that node, the dispatch has not failed and it has not produced a final answer: an owner decision is pending. The node reports this as its own status instead of error or “no response”:
[ { "agentId": 42, "status": "awaiting_approval", "approvalId": 118, "text": "…the last reply the target already showed, if it showed one…" }]approvalIdis the id of the open request in the workspace owner’s approvals inbox.textis present only when the target spoke before it paused (a draft, for example). There is novaluesentry: the target has not finished.- The dispatch returns as soon as the target pauses, so it does not wait for the owner and does not use up the node’s timeout while a person decides.
- The decision resumes the target, not the master. When the owner approves, rejects or requests changes, the target’s own workflow continues along the matching edge of its
approvalnode (for example, saving a page to Documents). The master’s turn has already ended, so the master is not called again and receives no result. Have the master tell the user what is pending and where to decide; do not promise the master will report back. - Route on it. A
routeronspecialist.0.status(withresultVarset tospecialist) takes one outgoing edge labelledokand one labelledawaiting_approval; setdefaultTargetto the branch for everything else (error,timeout):
{ "id": "route_specialist", "type": "router", "config": { "variable": "specialist.0.status", "defaultTarget": "say_failed" }}[ { "id": "e_ok", "source": "route_specialist", "target": "summarise", "label": "ok" }, { "id": "e_wait", "source": "route_specialist", "target": "say_waiting", "label": "awaiting_approval" }]- Relay it honestly. A
responsenode on that branch can say: “{{name}} has prepared this and it is waiting for an owner to approve it in Approvals (request #{{specialist.[0].approvalId}}). Nothing has been sent until the owner decides.”, followed by{{specialist.[0].text}}when present. Do not say it was sent, published or booked. - Only the owner decides. Approvals follow the approval node rules: a workspace owner decides, and a decision is asynchronous.
- Publishing (changed 2026-11-05, in production since that date).
publishWorkflowno longer refuses a workflow that contains anapprovalnode when an agent running it is the target of another workflow’ssub_agentnode (see the approval node page). The older refusal is gone.
Worked example
Section titled “Worked example”A front-desk coordinator that classifies the message, then dispatches to a billing specialist and a technical specialist at the same time, combining both replies into one response:
{ "id": "dispatch_specialists", "type": "sub_agent", "label": "Dispatch to Specialists", "config": { "targets": [ { "agentId": 42, "contextTemplate": "A customer is asking about their bill: \"{{userMessage}}\". Their account email is {{customerEmail}}." }, { "agentId": 43, "contextTemplate": "A customer has a technical question: \"{{userMessage}}\"." } ], "resultVar": "specialistAnswers", "timeoutMs": 45000 }, "position": { "x": 300, "y": 260 }}Followed by an LLM node that synthesizes both replies (never interpolate the raw array directly into a template — see the gotcha below):
{ "id": "synthesize", "type": "llm", "label": "Combine Specialist Replies", "config": { "promptTemplate": "Billing specialist said: {{specialistAnswers.[0].text}}\nTechnical specialist said: {{specialistAnswers.[1].text}}\n\nCombine these into one helpful reply to: \"{{userMessage}}\"", "outputVar": "combined_reply" }, "position": { "x": 400, "y": 260 }}Inputs and outputs: named values in and out
Section titled “Inputs and outputs: named values in and out”By default a sub-agent only receives the text contextTemplate renders to. Two optional fields on each target pass named values instead, so a non-LLM node in the sub-agent can use them directly and one sub-agent run can return several values.
Pass named values in with inputs
Section titled “Pass named values in with inputs”inputs maps a variable name to a Handlebars template. Each template is rendered against the master’s context (so channel.raw, a webhook’s result or an earlier node’s output are all reachable), and the rendered text is set as a variable in the sub-agent’s context before its workflow starts.
{ "agentId": 42, "contextTemplate": "Draft a support ticket for: {{userMessage}}", "inputs": { "accountId": "{{channel.raw.[support.account_id]}}", "categories": "{{jsonString categoryList}}" }}Inside the sub-agent, {{accountId}} and {{categories}} are ordinary variables: an llm node can read them in its prompt, and a condition, a router or an action’s argsTemplate can use them, with no extraction node. contextTemplate is still required and still becomes {{userMessage}}.
- Text only. A rendered value is a string. To pass a list or object, serialise it:
{{jsonString categoryList}}produces["billing","technical"]. To use a value as a number inside the sub-agent, convert it there with aset_variablenode (coerce: "number"). - Opt-in. Nothing is copied unless you name it. In particular
channelis not copied as a whole; pass the individual values you need. - Names. Letters, digits and underscores, starting with a letter, at most 64 characters. A name cannot be a reserved context key (
session,channel,attachment,userMessage, the ids and so on) or start with an underscore. This is refused when you save the workflow, and so is a template that does not parse. - Limits. At most 20 inputs per target, each template at most 2,000 characters, each rendered value at most 16,384 characters, and all of a target’s inputs at most 65,536 characters together.
- A bad input fails only that target. A value over a limit, or a template that cannot be rendered, gives that target
status: "error"(and starts no sub-agent session); other targets are unaffected. A variable that does not exist in the master renders as an empty string, as incontextTemplate, so check a required one with aconditionfirst. - Values can reach logs and traces. Rendered inputs are stored in the sub-agent’s run context. Do not route a secret or a signed URL into one unless you accept that.
Get named values back with outputs
Section titled “Get named values back with outputs”outputs lists the variables in the sub-agent’s final context to hand back. They arrive as values on that target’s entry in resultVar, with their original types, next to the usual text:
{ "agentId": 42, "contextTemplate": "Draft a support ticket for: {{userMessage}}", "outputs": ["ticketTitle", "ticketDescription", "ticketCategory"]}[ { "agentId": 42, "status": "ok", "text": "…the sub-agent's last reply…", "values": { "ticketTitle": "Booking shows success but no record", "ticketDescription": "…", "ticketCategory": "technical" } }]Downstream, read them as {{ticketDraft.[0].values.ticketTitle}} (with resultVar set to ticketDraft).
- Only what you name is read. Output names follow the same rules as input names: a reserved name, a name that starts with an underscore, or a name listed twice is refused when you save, and a reserved or underscore name is never returned at run time either.
- A missing value is not an error. A requested name that the sub-agent never set, or whose value is over 16,384 characters (or 65,536 in total), is left out of
valuesand listed inmissingValues, so the master can branch on it. valuesappears only on success. A target withstatus: "error"or"timeout"has none. A target with nooutputsreturns exactly the entry it always did.- Types are kept. A string stays a string, and a number produced by a
set_variablenode withcoerce: "number"stays a number. An LLM node’soutputVaris text.
Worked example: a ticket specialist
Section titled “Worked example: a ticket specialist”The master looks up the account’s ticket categories, a specialist sub-agent drafts the ticket from the conversation, and the master files it. Nothing is extracted by a model that is already a variable.
- Master: a webhook node stores the categories in
categoryList(using{{channel.raw.[support.account_id]}}in its request). - Master: a
sub_agentnode dispatches to the specialist:
{ "id": "draft_ticket", "type": "sub_agent", "label": "Draft the ticket", "config": { "targets": [ { "agentId": 42, "contextTemplate": "{{#each conversationHistory}}{{role}}: {{text}}\n{{/each}}Latest message: {{userMessage}}", "inputs": { "categories": "{{jsonString categoryList}}" }, "outputs": ["ticketTitle", "ticketDescription", "ticketCategory"] } ], "resultVar": "ticketDraft" }, "position": { "x": 300, "y": 200 }}- Specialist (agent 42): three
llmnodes read{{userMessage}}(the conversation) and{{categories}}, withoutputVarset toticketTitle,ticketDescriptionandticketCategory; the last one can usematchAnyOfso the category is one of the allowed labels. A response node ends the run. The text the specialist sees still contains any[[...]]marker lines from the incoming message (markers are not stripped fromuserMessage), so tell its prompts to ignore lines that start with[[. - Master: an
actionnode files the ticket, mixing master facts and the specialist’s values:
{ "id": "create_ticket", "type": "action", "label": "Create ticket", "config": { "customActionId": 9, "argsTemplate": "{\"organizationId\": \"{{channel.raw.[support.company]}}\", \"subject\": \"{{ticketDraft.[0].values.ticketTitle}}\", \"description\": \"{{ticketDraft.[0].values.ticketDescription}}\", \"category\": \"{{ticketDraft.[0].values.ticketCategory}}\"}", "outputVar": "createResult" }, "position": { "x": 500, "y": 200 }}Check ticketDraft.[0].status is ok before the action (a condition node on ticketDraft.[0].status == "ok"), since a failed target has no values.
Using master variables inside the sub-agent
Section titled “Using master variables inside the sub-agent”The sub-agent’s workflow cannot see the master’s variables. It only sees the text contextTemplate renders to, which arrives as the sub-agent’s opening message, {{userMessage}}. Its conversation history starts empty, and none of the master’s variables are copied over unless you pass them with inputs.
To use a master variable in the sub-agent’s LLM node:
- Put it into the target’s
contextTemplate, with a label so it is easy to find:Customer: {{name}}\nResume: {{resumeText}}. - In the sub-agent’s LLM node, reference
{{userMessage}}inpromptTemplate. - Most of the time you don’t need a variable at all: the sub-agent’s LLM node can read the labelled lines in
{{userMessage}}directly. Only if a non-LLM node (a condition, a tool’s arguments) needs the value as its own variable, pass it withinputsinstead of the text (no model call), or as a last resort extract it with an LLM node. Aset_variablenode cannot split values out of text, so it does not do this job.
channel is not available in the sub-agent. channel.raw, channel.trigger, channel.nortiaEnabled, channel.nortiaCompanyId and attachment are built only from the [[...]] markers on a message that arrives from outside (see Channels). A sub-agent’s run is started by the node, not by an incoming message, so none of that is parsed for it. Even a contextTemplate that renders to [[support:name=...]] lines arrives as plain text in {{userMessage}}. Pass the value as text (Support name: {{channel.raw.[support.name]}}) and extract it with an LLM node in the sub-agent if a non-LLM node needs it as a variable. To use one of those values in the sub-agent, pass it with inputs, for example "accountId": "{{channel.raw.[support.account_id]}}".
An object or array interpolated as {{obj}} renders [object Object]: interpolate its fields ({{obj.a}}) or serialize it to a string in an earlier node. Use {{jsonString var}} when the template is JSON-shaped and the value can contain newlines or quotes.
Giving the sub-agent the conversation
Section titled “Giving the sub-agent the conversation”A sub-agent starts with an empty conversation history: it does not see what the customer and the master said earlier. If its job is to read the conversation (to summarize it, or to draft a ticket from it), put the conversation into contextTemplate. The master’s conversationHistory is a list of { role, text } turns (role is user or model), and {{#each}} renders it:
Conversation so far:{{#each conversationHistory}}{{role}}: {{text}}{{/each}}Latest message: {{userMessage}}With a two-turn history this renders (checked against Wetel’s template engine):
Conversation so far:user: Booking shows success but no record in my accountmodel: Sorry about that. Want me to raise a ticket?
Latest message: Yes please raise a ticket, it's urgentconversationHistoryholds the earlier turns. The message being handled now is{{userMessage}}, so include both, as above.- The history includes the master’s own earlier replies (
roleismodel), and a model can echo them. In a live test, a master whose previous reply was a JSON-lookingFILED {...}line made the sub-agent’s model copy that JSON into its title and description. Tell the sub-agent’s prompt to use only the lines that start withuser:, or pass just{{userMessage}}when the latest message is enough. - A very long conversation is truncated to the session’s context window before it reaches the master’s context.
- The apostrophe in
it'ssurvives:contextTemplateis rendered without HTML escaping, like any text template. - Do not put this template in an
argsTemplateor a request body: those are JSON, and a newline or quote from a conversation would break it. Use it incontextTemplateand prompt text.
Which nodes in a sub-agent need a variable
Section titled “Which nodes in a sub-agent need a variable”What reaches the sub-agent as one block of text in {{userMessage}} is only what contextTemplate renders. What a node can do with that text depends on whether it calls a model (a value passed with inputs is already a variable, and every node can use it):
| Node in the sub-agent | Can it use the text? |
|---|---|
llm | Yes. The model reads Support name: Alex Tan inside {{userMessage}} like any other prompt text. No extraction needed. |
condition, switch, router | No. They test a variable. A value inside a sentence is not one, so something has to put it in a variable first. |
action, tool, webhook with the value in argsTemplate, a request body or fetchAsBase64Var | No, for the same reason: they put a variable’s value into a request, and they cannot pick one value out of a block of text. |
To give those nodes a value, pass it with inputs: it becomes a variable at no model cost. Without inputs, the only way to turn that text into a variable is an extraction llm node, which costs a model call. A set_variable node cannot do it, because it does not split text.
Alternative to inputs: keep those nodes in the master
Section titled “Alternative to inputs: keep those nodes in the master”The master still has the values (for a message that came from a channel, channel.raw too), so the cheapest design is to leave every non-LLM node that needs them in the master. Use the sub-agent only for the model work, and read what it returns from resultVar.
For example, a master workflow drafts a ticket’s subject and description in two sub-agents (concurrently), then creates the ticket itself with an action node. No extraction node and no channel.raw copy is needed anywhere:
{ "id": "draft_ticket", "type": "sub_agent", "label": "Draft subject and description", "config": { "targets": [ { "agentId": 42, "contextTemplate": "Write a short ticket subject for this request: \"{{userMessage}}\"" }, { "agentId": 43, "contextTemplate": "Write a clear ticket description for this request: \"{{userMessage}}\"" } ], "resultVar": "ticketDraft" }, "position": { "x": 300, "y": 200 }}{ "id": "create_ticket", "type": "action", "label": "Create ticket", "config": { "customActionId": 9, "argsTemplate": "{\"organizationId\": \"{{channel.raw.[support.company]}}\", \"requesterEmail\": \"{{channel.raw.[support.email]}}\", \"subject\": \"{{ticketDraft.[0].text}}\", \"description\": \"{{ticketDraft.[1].text}}\"}", "outputVar": "createResult" }, "position": { "x": 500, "y": 200 }}Each target writes exactly one thing, so each text is already the value the action needs, and the order of ticketDraft follows the order of targets. Check each entry’s status before using its text (see the gotchas below), for example with a condition on the draft results.
Only when a non-LLM node must run inside the sub-agent does it need an extraction llm node. In that case keep each extraction node’s prompt to one value, so every call stays small. A sub-agent starts with an empty conversation history, so there is no history to exclude.
Can a sub-agent run actions? Where should an action live?
Section titled “Can a sub-agent run actions? Where should an action live?”Yes. A sub-agent runs a full workflow of its own, so it can contain action, tool and webhook nodes, including certified operations. A linked identity-provider identity, and the product credentials linked to it, is copied onto the sub-agent’s session automatically, so a credentialed action works without extra wiring.
Where to put an action is a question of where its inputs come from, not of what a sub-agent is allowed to do:
| The action’s inputs are… | Put the action in… | Why |
|---|---|---|
| Worked out by the sub-agent itself, such as an invoice number the customer typed or a product the sub-agent’s model identifies | The sub-agent | The sub-agent has to extract them anyway, so the action costs nothing extra. A specialist that owns its lookup and its write-back is a normal use of the node. |
Facts the master already holds as proper values, such as ids and contact details from channel.raw | The master | They already exist as variables there. Either keep the action in the master, or pass the values down with inputs so the action can live in the sub-agent; do not send them as text and extract them again, which costs a model call for no gain. |
| A mix | Split it | Let the sub-agent produce only what needs a model, return it through resultVar, and have the master’s action combine it with the facts the master holds. |
What differs inside a sub-agent
Section titled “What differs inside a sub-agent”- No
channelorattachment, unless you pass individual values withinputs. See Using master variables inside the sub-agent. - It cannot pause for a human reply. An
await_replynode in a sub-agent’s workflow refuses to pause, because the sub-agent’s session exists only for the duration of the dispatch and nobody is watching it. That target comes back asstatus: "error"rather than the question being handed to the master as if it were the answer. Ask the human in the master instead, and dispatch afterwards. - An
approvalnode pauses it, and that is reported, not an error. The target returnsstatus: "awaiting_approval"with the request’sapprovalId; see A target that waits for an owner approval. - No retry. A target that timed out may already have run its own side-effecting calls, so it is never re-run for you.
- Timeout and depth. The default timeout is 30 seconds per target (120 seconds at most), and dispatch depth is capped at 5.
Success/failure edges
Section titled “Success/failure edges”Added 2026-10-15. A sub_agent node accepts the same opt-in success/failure edges as a tool node, but the failure edge is for configuration errors only: a node with no targets, or with more than 10 (both are normally refused when you save). A target that fails, times out or is refused (for example a bad input) does not take the failure edge: it is reported in resultVar with status: "error" or "timeout", as always, and the node follows success. So with labelled edges, still check each entry’s status on the success branch.
A failure edge without a success edge is refused at publish, and a node with neither label behaves exactly as before. See Failure routing for the full rules.
Gotchas
Section titled “Gotchas”{{resultVar}}on its own renders[object Object], not an error.ctx[resultVar]is an array — a bare{{specialistAnswers}}in any downstream template silently produces the literal string[object Object], not a runtime error, since Handlebars has no auto-JSON-stringify for{{var}}. Always index into a specific entry ({{specialistAnswers.[0].text}}) or use thejsonStringhelper if you genuinely need the whole array serialized. See Best Practices for the general pattern.- A single-target dispatch still produces an array.
resultVaris[{ agentId, status, text }], not a bare object — even with exactly one entry intargets, downstream templates must still index.[0]. - One target’s
status: 'error','timeout'or'awaiting_approval'never fails the node itself, and never blocks the other targets. Check each entry’s ownstatusbefore readingtext— a failed entry has notextfield, onlyerror. - Every target agent must have a published workflow. A target whose agent has no
workflowId(or an unpublished one) fails per-target withstatus: 'error'at execution time — it is not caught when you save or publish the master workflow. Check the target agents first when a specialist comes back aserrorwith no partner call in its trace. - Dispatch depth is capped at 5. A sub-agent’s own workflow can itself contain a
sub_agentnode, but a chain deeper than 5 dispatches is rejected — this is what stops an accidental cycle (A → B → A …) from recursing forever. - A sub-agent session is fully independent — it has its own turn history and its own cost tracking, and does not automatically inherit anything from the master session beyond what you put in
contextTemplate. One exception: a linked identity-provider identity (and any partner product credentials linked to it) is copied onto every dispatched sub-agent session automatically — a certified operation on a sub-agent’s own workflow that needs a session-scoped credential works without any extra wiring incontextTemplate. - Never retried on timeout or error. If you need a “try again if it fails” pattern, build that explicitly in your own workflow (e.g. a router branch that re-dispatches based on
status) rather than expecting the node to retry for you.
In a mock-mode test
Section titled “In a mock-mode test”Live in production since 2026-10-24. In a mock-mode draft test or regression replay, a sub_agent node with a mock (a draftMocks entry or its own mockResponse) dispatches nothing: no child session, no child run, no model calls, and resultVar is the mock as given. Without a mock it dispatches as usual and every child run is in mock mode too, sharing the test’s cap of 20 real model calls. The parent’s draftMocks do not reach the child graph. A regression replay in mock mode mocks this node from the recorded run. See Testing a Workflow Safely.
Next steps
Section titled “Next steps”- LLM Node, Condition Node, Router Node, Response Node — other node types.
- Workflows Overview — the full node/edge model.
- Workflow Best Practices — templating rules, including the raw-object-interpolation gotcha above.
- Worked Examples & Demos — complete multi-node workflows.
- Workflows API Reference — the mutations for creating and publishing workflows.