Sub-Agents
Decompose work across multiple specialized agents with a visible delegation log.
What is this?#
Sub-agents are the canonical multi-agent pattern: a top-level supervisor LLM orchestrates one or more specialized sub-agents by exposing each of them as a tool. The supervisor decides what to delegate, the sub-agents do their narrow job, and their results flow back up to the supervisor's next step.
This is fundamentally the same shape as tool-calling, but each "tool" is itself a full-blown agent with its own system prompt and (often) its own tools, memory, and model.
When should I use this?#
Reach for sub-agents when a task has distinct specialized sub-tasks that each benefit from their own focus:
- Research → Write → Critique pipelines, where each stage needs a different system prompt and temperature.
- Router + specialists, where one agent classifies the request and dispatches to the right expert.
- Divide-and-conquer — any problem that fits cleanly into parallel or sequential sub-problems.
The example below uses the Research → Write → Critique shape as the canonical example.
Setting up sub-agents#
Each sub-agent is a full create_agent(...) call with its own model,
its own system prompt, and (optionally) its own tools. They don't share
memory or tools with the supervisor; the supervisor only ever sees
what the sub-agent returns.
_SUB_LLM_CONFIG = LLMConfig({"model": "gpt-4o-mini", "stream": False})
_research_agent = ConversableAgent(
name="research_sub_agent",
system_message=dedent(
"""
You are a research sub-agent. Given a topic, produce a concise
bulleted list of 3-5 key facts. No preamble, no closing.
"""
).strip(),
llm_config=_SUB_LLM_CONFIG,
human_input_mode="NEVER",
max_consecutive_auto_reply=1,
)
_writing_agent = ConversableAgent(
name="writing_sub_agent",
system_message=dedent(
"""
You are a writing sub-agent. Given a brief and optional source
facts, produce a polished 1-paragraph draft. Be clear and
concrete. No preamble.
"""
).strip(),
llm_config=_SUB_LLM_CONFIG,
human_input_mode="NEVER",
max_consecutive_auto_reply=1,
)
_critique_agent = ConversableAgent(
name="critique_sub_agent",
system_message=dedent(
"""
You are an editorial critique sub-agent. Given a draft, produce
2-3 crisp, actionable critiques. No preamble.
"""
).strip(),
llm_config=_SUB_LLM_CONFIG,
human_input_mode="NEVER",
max_consecutive_auto_reply=1,
)Keep sub-agent system prompts narrow and focused. The point of this pattern is that each one does one thing well. If a sub-agent needs to know the whole user context to do its job, that's a signal the boundary is wrong.
Exposing sub-agents as tools#
The supervisor delegates by calling tools. Each tool is a thin wrapper
around sub_agent.invoke(...) that:
- Runs the sub-agent synchronously on the supplied
taskstring. - Records the delegation into a
delegationsslot in shared agent state (so the UI can render a live log). - Returns the sub-agent's final message as a
ToolMessage, which the supervisor sees as a normal tool result on its next turn.
# Each @tool wraps a sub-agent invocation. The supervisor LLM "calls"
# these tools to delegate work; each call asynchronously runs the
# matching sub-agent, records the delegation into shared state via
# ContextVariables, and returns a ReplyResult the supervisor reads as
# its tool output on the next step.
@tool()
async def research_agent(
context_variables: ContextVariables,
task: str,
) -> ReplyResult:
"""Delegate a research task to the research sub-agent.
Use for: gathering facts, background, definitions, statistics. Returns
a bulleted list of key facts.
Args:
task: The specific research question or topic to investigate.
"""
return await _run_delegation(
context_variables, "research_agent", _research_agent, task
)
@tool()
async def writing_agent(
context_variables: ContextVariables,
task: str,
) -> ReplyResult:
"""Delegate a drafting task to the writing sub-agent.
Use for: producing a polished paragraph, draft, or summary. Pass
relevant facts from prior research inside ``task``.
Args:
task: The brief plus any relevant facts the writer should use.
"""
return await _run_delegation(
context_variables, "writing_agent", _writing_agent, task
)
@tool()
async def critique_agent(
context_variables: ContextVariables,
task: str,
) -> ReplyResult:
"""Delegate a critique task to the critique sub-agent.
Use for: reviewing a draft and suggesting concrete improvements.
Args:
task: The draft to critique (paste it directly into ``task``).
"""
return await _run_delegation(
context_variables, "critique_agent", _critique_agent, task
)
This is where CopilotKit's shared-state channel earns its keep: the
supervisor's tool calls mutate delegations as they happen, and the
frontend renders every new entry live.
Rendering a live delegation log#
On the frontend, the delegation log is just a reactive render of the
delegations slot. Subscribe with useAgent({ updates: [OnStateChanged, OnRunStatusChanged] }), read agent.state.delegations,
and render one card per entry.
/**
* Live delegation log — renders the `delegations` slot of agent state.
*
* Each entry corresponds to one invocation of an AG2 sub-agent. The list
* grows in real time as the supervisor fans work out to its children;
* each delegation is appended via the supervisor's tool returning a
* ReplyResult with updated ContextVariables, which AG-UI surfaces to
* the UI through agent state.
*/
export function DelegationLog({ delegations, isRunning }: DelegationLogProps) {
return (
<div
data-testid="delegation-log"
className="w-full h-full flex flex-col bg-white rounded-2xl shadow-sm border border-[#DBDBE5] overflow-hidden"
>
<div className="flex items-center justify-between px-6 py-3 border-b border-[#E9E9EF] bg-[#FAFAFC]">
<div className="flex items-center gap-3">
<span className="text-lg font-semibold text-[#010507]">
Sub-agent delegations
</span>
{isRunning && (
<span
data-testid="supervisor-running"
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full border border-[#BEC2FF] bg-[#BEC2FF1A] text-[#010507] text-[10px] font-semibold uppercase tracking-[0.12em]"
>
<span className="w-1.5 h-1.5 rounded-full bg-[#010507] animate-pulse" />
Supervisor running
</span>
)}
</div>
<span
data-testid="delegation-count"
className="text-xs font-mono text-[#838389]"
>
{delegations.length} calls
</span>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{delegations.length === 0 ? (
<p className="text-[#838389] italic text-sm">
Ask the supervisor to complete a task. Every sub-agent it calls will
appear here.
</p>
) : (
delegations.map((d, idx) => {
const style = SUB_AGENT_STYLE[d.sub_agent];
return (
<div
key={d.id}
data-testid="delegation-entry"
className="border border-[#E9E9EF] rounded-xl p-3 bg-[#FAFAFC]"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-[#AFAFB7]">
#{idx + 1}
</span>
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-[0.1em] border ${style.color}`}
>
<span>{style.emoji}</span>
<span>{style.label}</span>
</span>
</div>
<span
className={`text-[10px] uppercase tracking-[0.12em] font-semibold ${STATUS_BADGE[d.status]}`}
>
{d.status}
</span>
</div>
<div className="text-xs text-[#57575B] mb-2">
<span className="font-semibold text-[#010507]">Task: </span>
{d.task}
</div>
<div className="text-sm text-[#010507] whitespace-pre-wrap bg-white rounded-lg p-2.5 border border-[#E9E9EF]">
{d.result}
</div>
</div>
);
})
)}
</div>
</div>
);
}The result: as the supervisor fans work out to its sub-agents, the log grows in real time, giving the user visibility into a process that would otherwise be a long opaque spinner.
Related#
- Shared State — the channel that makes the delegation log live.
- State streaming — stream individual sub-agent outputs token-by-token inside each log entry.
