Sub-agents, skills or flows?
Quick start
Give each sub-agent adescription (the lead model reads it to choose), then
pass them to the lead as subagents:
AgentExecutor.execute() takes the same subagents and maxSubagentDepth
options. Use it when you need hooks or tracing on the lead run - sub-agents
inherit those (see Inheritance). Approvals work
from createAgent() too (see Approvals).
How it works
Withsubagents, the lead agent gets:
- An Available sub-agents block in its system prompt: one line per
sub-agent (
- name: description), plus a sentence saying that a sub-agent sees only the prompt it is given. - ONE tool,
task, with the input{ agent: <one of the names>, prompt: string, description: string, background?: boolean, taskId?: string, mode?: 'new' | 'resume' | 'fork' }(descriptionis a 3-5 word label used in events and hooks;taskIdandmodecontinue an earlier task, see Continuing a task). - Three tools for background tasks:
agent_status,agent_awaitandagent_cancel(see Background sub-agents).
task, the named sub-agent runs on prompt alone. The
tool result is the sub-agent’s final text plus a small footer the lead can
use:
maxSteps, or is
aborted - the lead gets an error result (isError: true) that says why, for
example Sub-agent 'researcher' used all 10 of its steps (maxSteps) without giving a final answer. An unknown agent name is also an error result listing
the valid names. Sub-agent token usage is added to the lead’s result.usage.
Errors at setup time are thrown with a fix: a sub-agent without a
description, a value that is not a createAgent() agent, or a tool of your
own already named task (or agent_status, agent_await, agent_cancel).
Continuing a task
Eachtask call runs in a child conversation with a taskId (task_1,
task_2, … in the footer). The lead can come back to it:
The model learns this from the
task tool description, so asking a follow-up
needs no code. A lead that keeps its work across turns or restarts needs a
store and a session:
- With
createAgent({ store })(store.sessions) and a lead run that has asessionId(a checkpointedsend(message, { sessionId })or a session turn), each task is saved instore.sessionsafter its run, under a key hashed from the lead session id and thetaskId. They outlive the lead run: later turns of the same session, a lead resumed withagent.resume()orapprovals.resolve(), and a new process on the same durable store can continue them. AtaskIdfrom another lead session never matches. The session store has no list API, so these entries are not listed or deleted with the lead session; delete the store’s data to drop them. ForAgentExecutor.execute(), set the store withwithSubagentOptions(subagents, { sessions }). - Otherwise (no session store, or a lead run without a
sessionId) a task lives in memory for that oneexecute()/send()call: a run that pauses for approval and is resumed starts with none.
stop, length or max-steps) are saved, so a
task that failed or was cancelled cannot be continued. Errors reach the lead
as structured tool errors: an unknown taskId, one of another lead session,
or one that belongs to another sub-agent fails with
LOUSHO_SUBAGENT_TASK_NOT_FOUND; a task that is still running (a background
task that has not ended) fails with LOUSHO_SUBAGENT_TASK_BUSY, telling the
model to agent_await or agent_cancel it first.
A resumed or forked task is a normal task call otherwise: it can run in the
background (it keeps its taskId), an approval inside it pauses and resumes
the lead like any sub-agent approval, and its usage counts toward the lead’s
usage and limits. The child sees its whole transcript again on every
resume, so a long-lived task costs more tokens each time.
Parallel tasks
Tool calls of one model turn run concurrently (seetoolConcurrency), so when
the lead calls task several times in one turn, those sub-agents run in
parallel. Results reach the lead’s transcript in the order the model made the
calls. Cap it with toolConcurrency on the lead (1 runs them one at a time).
Background sub-agents
Withbackground: true, task starts the sub-agent and returns at once with
{ taskId, status: 'running', agent }, so the lead can keep working (or start
more tasks) while it runs. The lead then uses:
At most
maxConcurrent background sub-agents of one lead run run at once
(default 3); further background: true calls get status: 'queued' and start
as slots free. Set it with subagentOptions on createAgent():
AgentExecutor.execute(), attach the same options to the subagents
value with withSubagentOptions(subagents, options), which returns it. On
createAgent(), subagentOptions override options attached that way.
A background sub-agent inherits from the lead run like a synchronous one
(hooks, tracing, event listeners, usage roll-up), and maxSubagentDepth applies the
same way. Aborting the lead’s signal cancels its background sub-agents, queued
or running.
When the lead run ends
Background tasks belong to one run (oneexecute() / send() / stream()).
When that run ends, however it ends, the tasks still queued or running are
cancelled by default (their runs are aborted). With
awaitBackgroundOnFinish: true the run instead waits for them to finish
before it resolves (an aborted or failed lead run still cancels them). Either
way the run resolves only once their runs have stopped, so no sub-agent event
reaches the run’s listeners after it; with awaitBackgroundOnFinish their
events arrive before the lead’s run.done.
The result lists every background task of the run with its final status:
result.backgroundTasks holds { taskId, agent, status, elapsedMs, error?, approvalId?, toolName? } per task (the agent_status shape) and is absent
when the run started none. Use agent_await in the lead (the system prompt
tells it to) to get their answers into the conversation.
The executor hook behind this is public: ExecuteOptions.onRunEnd is called
exactly once per run, with { result } (whatever the finishReason) or
{ error }; the run settles after it returns. Background tasks are reported
on result before your onRunEnd sees it.
Limits:
- A lead run that pauses for approval (
finishReason: 'awaiting-approval') ends there: its background tasks are cancelled (or awaited) like at any other end, and the resumed run starts with none. The resumed run is a separate run with no link to the paused one’s tasks (it may even run in another process), so they cannot be kept alive across the pause. - A background sub-agent that needs approval stops with
status: 'awaiting-approval'(with the child’sapprovalIdandtoolName); its call does not run, and it cannot be resumed through the approval store. Run that task synchronously (background: false) when it may need approval.
Dynamic catalogs
Instead of a fixed record, pass a catalog withlist() and resolve(name).
list() is called at the start of every run that offers the task tool, so
the set can change between runs; resolve() is called when the lead picks a
name (return undefined for an unknown one).
Remote sub-agents
remoteAgent() uses an agent you already deployed (lousho deploy: the node
server, Docker or a Cloudflare Worker) as a sub-agent. It goes in subagents
next to local ones, and the lead delegates to it with the same task tool,
including background: true.
POST <url>/chat with
{ sessionId, input }, read as the SSE event stream, see
Deployment), sends the task prompt and returns the
remote agent’s final text (its object as JSON when the deployed agent has an
output schema, see Structured output), followed by
a footer naming the session and the taskId. A task call that resumes that taskId posts to the same remote
session, so the remote agent continues with its own history (keep it in a
store on the remote side); a remote task cannot be forked. The remote
agent sees only that prompt, and runs with its own model, tools and limits, so
the lead’s runtime (hooks, sandbox, approval store) does not reach it. The
lead run’s abort signal aborts the request. Options: url, auth, name,
description, headers, and fetch (inject one in tests; the
serveFetch() routes of an in-process agent work as a fake deployment).
Failures (the agent is unreachable, a 401 or other non-2xx answer, a malformed
stream, or a remote run that ends in an error) reach the lead as the
structured tool error with the code LOUSHO_REMOTE_REQUEST_FAILED (LOUSHO_REMOTE_UNAUTHORIZED for a 401); the token is
never part of an error or an event.
Remote approvals
If the remote run pauses for an approval, the lead run pauses on it exactly as for a local sub-agent:result.finishReason is 'awaiting-approval', and the
pending approval in agent.approvals.list() (and in channel buttons, the dev
chat and ACP permission requests) has the remote tool’s name and input, with
subagentPath: ['researcher']. A remote ask_question call arrives as a
question (kind: 'question') that agent.approvals.answer() answers.
Deciding it on the lead (resolve, streamResolve, answer) posts the
decision to the remote agent’s approvals route
(POST <url>/chat/<session>/approvals/<id>), reads the continuation, and its
final answer becomes the task result; a continuation that pauses again pauses
the lead again.
taskId and the sub-agent’s name, so a fresh lead process on
the same store can decide it; the bearer token is never stored (it is read from
the remoteAgent() options again). A failure while deciding (a 401, any other
non-2xx answer such as the remote’s 404 for an approval no longer pending, a
network error) is the structured tool error of that task call with the codes
above, and the lead run continues. Only a run without an approval store (a bare
AgentExecutor) still fails the task with LOUSHO_SESSION_AWAITING_APPROVAL,
naming the remote session and approval id to decide on the remote agent.
Depth
maxSubagentDepth (default 1) bounds how deep sub-agents nest. With the
default, the lead can call sub-agents but they cannot call sub-agents of their
own: a run at the limit is simply not offered the task tool, even if it was
created with subagents. maxSubagentDepth: 2 lets the lead’s sub-agents
delegate once more. The top-level run’s value applies to the whole tree; a
sub-agent’s own maxSubagentDepth only matters when it runs as a top-level
agent.
What a sub-agent inherits
The sub-agent keeps its own instructions, model/provider, tools, skills andmaxSteps, and its context is isolated. From the run that called it, it
inherits:
createDelegateTool() children inherit the same way.
Hooks inside sub-agents
A parent’s hooks apply to its sub-agents by default.ctx.subagent tells a
hook it is running inside one - the sub-agent’s name, its depth (1 for a
sub-agent of the top-level run), the lead’s toolCallId that started it, and
the enclosing sub-agent as parent when nested deeper. A hook that should only
see the top-level run returns early:
Events
agent.stream() / AgentExecutor.stream() on the lead stream each
sub-agent’s run inside the same stream - steps, text deltas, tool events and
errors - with a subagent field on every one of its events, so a UI can nest
sub-agent activity under the lead’s task call (subagent.toolCallId). See
Streaming: sub-agents.
A listener on the lead (createAgent({ onEvent }) or onAgentEvent) gets
the same events, sub-agents’ included, as they happen. See
Listening without iterating.
Approvals inside a sub-agent
When a sub-agent calls a tool thatneedsApproval, the whole run pauses:
the lead’s execute() resolves with finishReason: 'awaiting-approval' and
an approvalId, and the approval store holds ONE record whose pending call is
the sub-agent’s call (toolName, args) with subagentPath naming the
sub-agents it runs inside (e.g. ['researcher']). Approve or reject it with
resumeAfterApproval(), passing the same subagents option as the paused run:
task result, and then
continues the lead. If the sub-agent needs another approval, the run pauses
again with a new approvalId. This works at any depth and for
createDelegateTool() children (pass the registry that holds the delegate
tool).
With a createAgent() lead, lead.send() pauses the same way and
lead.approvals.resolve({ id, approved }) resumes it (the lead already knows
its subagents); the lead’s approve option decides its sub-agents’ calls too.
Guarantees and limits:
- No call runs twice and none is lost: the sub-agent’s paused state is stored
inside the lead’s approval record (plain JSON, so any
ApprovalStoreworks), and the approved call runs exactly once, on resume. - Other tool calls of the same lead turn that finished keep their results and are not run again.
- One approval is pending per run. If two sub-agents pause in the same turn,
the run pauses on the first (in call order); the second sub-agent stops and
the lead gets an error result for that
taskcall saying its call was not run, so it can ask again after the approval. - A
taskcall’s pre/post tool hooks fire again on resume, like the hooks of any approved tool. - Approvals need an
approvalStoreon the lead run, whichcreateAgent()does not take yet; useAgentExecutor.execute()for the lead. Without one, the sub-agent’s call becomes an error result and nothing runs. - Token usage keeps adding up across the pause: the sub-agent’s usage before
the pause is in the paused result, and what it spends after the resume is
added to the resumed lead’s
result.usage.
createDelegateTool()
createDelegateTool({ agent, provider, toolRegistry }) wraps one child agent as
a tool you name and register yourself, with its own maxDepth guard. It runs
on the same delegation core as task, so it inherits the parent runtime the
same way, and its result shape ({ text, usage }) is unchanged. Prefer
subagents for new code: one tool, a prompt listing, parallel tasks and depth
limits come for free.