Recipe: Delegate to a Specialist (Sub-Agent Inputs and Outputs)
This content is not available in your language yet.
This recipe splits one job between two agents. A main agent talks to the customer and holds the facts (the order id, the conversation). A specialist agent, with its own prompt and knowledge base, answers one narrow question and hands back structured values. The main workflow then branches on those values with no extra model call.
Status: live in production: the
sub_agentnode, withinputs,outputsand success/failure edges.
Prerequisites
Section titled “Prerequisites”- Two agents in the same workspace, each with a published workflow. Here the main agent is
57and the returns specialist is64, which has a knowledge base with your returns policy. - A dashboard sign-in (
Authorization: Bearer <token>plusx-huat-platform: customer) to edit workflows. - The order id reaches the main workflow as per-turn data (
turnContext: { "orderId": "A-1042" }onsdkSendMessage), or from any earlier node.
How it fits together
Section titled “How it fits together”sequenceDiagram participant M as Main workflow (agent 57) participant S as Specialist (agent 64, own session) M->>S: contextTemplate text + inputs {orderId} Note over S: Starts with an empty history.<br/>Sees only userMessage and the inputs. S->>S: llm (knowledge base) sets decision, reason S-->>M: resultVar [{status, text, values {decision, reason}}] M->>M: set_variable, then switch on decisionWhat the specialist sees
Section titled “What the specialist sees”A sub-agent run starts nearly empty. It does not see the main conversation, the main workflow’s variables, channel facts or attachments. It sees:
userMessage: whatever the target’scontextTemplaterenders to, against the main workflow’s context;- one variable per entry in
inputs(text only); - its own agent’s settings, knowledge base and published workflow.
There are two ways to get data across, and you can use both:
| Way | Good for | Costs |
|---|---|---|
contextTemplate | Prose a model reads: the customer’s question, a labelled summary, the conversation so far. | Nothing extra; an llm node in the specialist reads it as the message. |
inputs | Values a non-LLM node needs as a variable: an id for an action, a value a switch tests. | Nothing extra; without it you would need an extraction llm call in the specialist. |
Step 1: The specialist’s workflow (agent 64)
Section titled “Step 1: The specialist’s workflow (agent 64)”Two llm nodes write the values the main workflow asks for, then a response node ends the run. The labels approve, decline and need_info are chosen so that none contains another: matchAnyOf stores the label that appears earliest in the model’s answer, so a pair such as eligible and ineligible would be confused.
{ "input": { "id": 90, "nodes": [ { "id": "start", "type": "start", "label": "Start", "config": {}, "position": { "x": 0, "y": 0 } }, { "id": "decide", "type": "llm", "label": "Decide eligibility", "config": { "promptTemplate": "You check return requests against the returns policy in your reference material. Order {{orderId}}. Decide whether the request in the message is eligible for a return. Answer with exactly one word: approve, decline or need_info.", "knowledgeBaseId": 33, "matchAnyOf": ["approve", "decline", "need_info"], "outputVar": "decision", "streamOutput": false }, "position": { "x": 0, "y": 100 } }, { "id": "explain", "type": "llm", "label": "Give the reason", "config": { "promptTemplate": "The decision on the return request in the message was {{decision}} under the returns policy in your reference material. In one sentence a customer can read, give the reason. If more information is needed, say exactly what.", "knowledgeBaseId": 33, "outputVar": "reason", "streamOutput": false }, "position": { "x": 0, "y": 200 } }, { "id": "done", "type": "response", "label": "Done", "config": { "messageTemplate": "{{decision}}: {{reason}}" }, "position": { "x": 0, "y": 300 } } ], "edges": [ { "id": "e1", "source": "start", "target": "decide" }, { "id": "e2", "source": "decide", "target": "explain" }, { "id": "e3", "source": "explain", "target": "done" } ] }}Send it with updateWorkflow, then publishWorkflow and attach it to agent 64. A target whose agent has no published workflow comes back as status: "error" at run time; saving the main workflow does not check this.
Step 2: The main workflow’s dispatch (agent 57)
Section titled “Step 2: The main workflow’s dispatch (agent 57)”{ "id": "ask_returns", "type": "sub_agent", "label": "Ask the returns specialist", "config": { "targets": [ { "agentId": 64, "contextTemplate": "Customer request: {{userMessage}}", "inputs": { "orderId": "{{turn.context.orderId}}" }, "outputs": ["decision", "reason"] } ], "resultVar": "returnsCheck", "timeoutMs": 30000 }, "position": { "x": 0, "y": 100 }}returnsCheck is always an array, one entry per target, in targets order:
[ { "agentId": 64, "status": "ok", "text": "approve: The item was delivered 9 days ago, within the 30-day window.", "values": { "decision": "approve", "reason": "The item was delivered 9 days ago, within the 30-day window." } }]values appears only when status is ok. A requested output the specialist never set (or one over 16,384 characters) is left out and named in missingValues.
Step 3: Branch on what came back
Section titled “Step 3: Branch on what came back”Copy the values into flat variables with a set_variable node (a template of a missing path renders empty text, so this never fails), then route with a switch:
[ { "id": "unpack", "type": "set_variable", "label": "Unpack the result", "config": { "assignments": [ { "variable": "checkStatus", "kind": "template", "source": "{{returnsCheck.[0].status}}" }, { "variable": "decision", "kind": "template", "source": "{{returnsCheck.[0].values.decision}}" }, { "variable": "reason", "kind": "template", "source": "{{returnsCheck.[0].values.reason}}" } ] }, "position": { "x": 0, "y": 200 } }, { "id": "route", "type": "switch", "label": "Route on the decision", "config": { "cases": [ { "label": "unavailable", "expression": "checkStatus != \"ok\"" }, { "label": "approve", "expression": "decision == \"approve\"" }, { "label": "decline", "expression": "decision == \"decline\"" } ] }, "position": { "x": 0, "y": 300 } }]Edges from route: unavailable to a response such as “I can’t check returns right now; a colleague will follow up”, approve to the next step (an action that opens the return), decline to a response that quotes {{reason}}, and default (which covers need_info and anything unexpected) to a response that asks for what {{reason}} names.
Give ask_returns itself a success edge to unpack and a failure edge to the “unavailable” reply. Its failure edge is taken only for a configuration error (no targets, or more than 10); a specialist that fails or times out still takes success with status: "error" or "timeout", which is why the unavailable case checks checkStatus first.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Targets per node | 1 to 10, run at the same time |
| Timeout per target | 30 s by default, at most 120 s |
inputs per target | 20; template 2,000 characters; value 16,384; all inputs 65,536 together |
outputs per target | 20; value 16,384 characters; 65,536 together |
| Dispatch depth (a sub-agent of a sub-agent …) | 5 |
| Retries | None: a target that timed out may already have acted |
Input and output names use letters, digits and underscores, start with a letter, and cannot be a reserved context key or start with _; updateWorkflow refuses them otherwise.
Cost and tracing
Section titled “Cost and tracing”- Each target runs as its own session with its own model calls, billed to the same workspace and reported against the specialist agent, so you can see what each specialist costs. The child runs under the same API key’s model allowlist as the main session.
- The specialist’s run shares the main run’s
traceId(workflowRun), and every outbound call it makes carries the samex-wetel-trace-idheader, so one id finds the whole turn in your logs. - In the main run’s trace, the
ask_returnsrow shows the dispatch; the specialist’s nodes are in the run of its own session, whoseclientExternalIdbegins withsub-agent:and the main session’s id.
Common errors
Section titled “Common errors”| What you see | Why | Fix |
|---|---|---|
status: "error" and no partner call in the specialist | The target agent has no published workflow, or is in another workspace. | Publish and attach the specialist’s workflow. |
status: "error" from a bad input | An input over its limit, or a template that cannot render. Only that target fails. | Shorten the value; pass lists with {{jsonString list}}. |
status: "timeout" | The specialist took longer than timeoutMs. | Raise timeoutMs (120 s at most) or make the specialist do less. |
decision is in missingValues | The specialist never set it (for example a branch skipped the node). | Make every path of the specialist set each requested output. |
A reply shows [object Object] | A template rendered {{returnsCheck}} or {{returnsCheck.[0].values}} whole. | Index a field: {{returnsCheck.[0].values.reason}}. |
updateWorkflow refuses an input or output name (BAD_USER_INPUT) | Reserved, starts with _, or listed twice. | Rename it. |
The specialist’s await_reply makes the target fail | A sub-agent cannot pause to ask the customer. | Ask in the main workflow, then dispatch. |
Test checklist
Section titled “Test checklist”- An eligible request reaches the
approvebranch, withdecisionandreasonfilled. - A request missing a detail takes
defaultand the reply asks for it. - Unpublishing the specialist’s workflow makes the main workflow take
unavailable, with no raw error in the reply. - The specialist’s session shows up separately in usage, and its run has the main run’s
traceId. - No secret or signed URL is passed through
inputs(they are stored in the specialist’s run context).
What this does not do
Section titled “What this does not do”- The specialist cannot hold a conversation: it runs once per dispatch and cannot pause for a reply.
- It does not inherit the main conversation,
channelfacts or attachments; pass what it needs. - It does not retry a failed or slow specialist.
- Mock-mode tests of sub-agents are live in production since 2026-10-24; a draft test with
draftSideEffects: LIVE(workspace owner only) dispatches for real.