CopilotKit

Sub-Agents

Decompose work across multiple specialized agents with a visible delegation log.


"""CrewAI Flow backing the Sub-Agents demo.Mirrors `langgraph-python/src/agents/subagents.py` but uses CrewAI'snative `Crew` + `Task` primitives for the three specialised sub-agents.Architecture------------A top-level "supervisor" LLM (driven directly via `litellm.acompletion`)orchestrates three single-agent CrewAI crews exposed to the LLM astool calls:  - `research_agent`  — gathers facts (research crew)  - `writing_agent`   — drafts prose (writing crew)  - `critique_agent`  — reviews drafts (critique crew)Each delegation runs the matching crew synchronously via `kickoff()`inside an asyncio thread (to avoid blocking the event loop), appends a`Delegation = {id, sub_agent, task, status, result}` entry to`state["delegations"]`, and emits a STATE_SNAPSHOT so the UI'sdelegation log renders updates live.Why a Flow + tool calls instead of a single supervisor Crew?------------------------------------------------------------CrewAI's hierarchical / sequential `Process` modes orchestrate sub-agentsinternally and surface only the final crew output through the AG-UIbridge — every intermediate sub-task / delegation is opaque to theclient. The brief explicitly requires that "each delegation appends aDelegation entry to state and the UI renders a live delegation log",which mandates per-delegation visibility.The cleanest fit is therefore: each sub-agent is a real CrewAI Crew(authentic CrewAI primitive), the supervisor is a litellm-driven LLMthat exposes the three crews as tools, and the supervisor wrapperflow emits state snapshots after every delegation. This is the sameshape `langgraph-python/src/agents/subagents.py` uses, ported to CrewAIwhere each sub-graph is replaced by a real `Crew(agents=[...],tasks=[...])`."""from __future__ import annotationsimport asyncioimport jsonimport uuidfrom typing import List, Literal, Optionalfrom crewai import Agent, Crew, Process, Taskfrom crewai.flow.flow import Flow, startfrom litellm import acompletionfrom pydantic import BaseModel, Fieldfrom ag_ui_crewai import CopilotKitState, copilotkit_emit_state, copilotkit_stream# ---------------------------------------------------------------------------# Shared state# ---------------------------------------------------------------------------SubAgentName = Literal["research_agent", "writing_agent", "critique_agent"]class Delegation(BaseModel):    """Shape of one entry in the delegation log.    Mirrors the LangGraph reference 1:1 so the frontend type can be    shared verbatim across runtimes.    """    id: str    sub_agent: SubAgentName    task: str    status: Literal["running", "completed", "failed"]    result: str = ""class AgentState(CopilotKitState):    """Shared state. `delegations` is rendered as a live log in the UI."""    delegations: List[Delegation] = Field(default_factory=list)# ---------------------------------------------------------------------------# Sub-agent crews (each is a real, single-agent CrewAI Crew)# ---------------------------------------------------------------------------_LLM = "gpt-4o-mini"# Each sub-agent is a real, single-agent CrewAI Crew with its own# Agent role/goal/backstory and a single Task. They don't share# memory or tools with the supervisor — the supervisor only sees# the crew's final raw output (returned via `Crew.kickoff(...)`).def _build_research_crew() -> Crew:    researcher = Agent(        role="Researcher",        goal="Produce a concise bulleted list of 3-5 key facts on the topic.",        backstory=(            "You are a research sub-agent. You gather and distil "            "information into short, structured bullets. No preamble."        ),        verbose=False,        allow_delegation=False,    )    research_task = Task(        description=(            "Topic: {task}\n\n"            "Produce a concise bulleted list of 3-5 key facts about the "            "topic. Each bullet ≤ 1 short sentence. No preamble or "            "closing remarks."        ),        expected_output="3-5 short bullets, one per line, prefixed with '- '.",        agent=researcher,    )    return Crew(        agents=[researcher],        tasks=[research_task],        process=Process.sequential,        verbose=False,        chat_llm=_LLM,    )def _build_writing_crew() -> Crew:    writer = Agent(        role="Writer",        goal="Turn a brief and any source facts into a polished single paragraph.",        backstory=(            "You are a writing sub-agent. You take a brief plus optional "            "facts and produce one polished paragraph. Be clear and "            "concrete. No preamble."        ),        verbose=False,        allow_delegation=False,    )    writing_task = Task(        description=(            "Brief and source material:\n{task}\n\n"            "Produce one polished paragraph (3-6 sentences). No "            "headings, no bullet list, no preamble."        ),        expected_output="Exactly one polished paragraph.",        agent=writer,    )    return Crew(        agents=[writer],        tasks=[writing_task],        process=Process.sequential,        verbose=False,        chat_llm=_LLM,    )def _build_critique_crew() -> Crew:    critic = Agent(        role="Editorial Critic",        goal="Give 2-3 crisp, actionable critiques of a draft.",        backstory=(            "You are a critique sub-agent. You read a draft and offer "            "2-3 crisp, actionable improvements. No preamble, no rewrite."        ),        verbose=False,        allow_delegation=False,    )    critique_task = Task(        description=(            "Draft to critique:\n{task}\n\n"            "Provide 2-3 crisp, actionable critiques as short bullets "            "(one critique per bullet). No preamble, no rewrite of the "            "draft itself."        ),        expected_output="2-3 short bullet-point critiques.",        agent=critic,    )    return Crew(        agents=[critic],        tasks=[critique_task],        process=Process.sequential,        verbose=False,        chat_llm=_LLM,    )# Lazy singletons — each Crew is hot once built, so reuse across requests.# Built lazily so import is cheap and aimock-mocked tests don't trigger# any Crew machinery at module load._RESEARCH_CREW: Optional[Crew] = None_WRITING_CREW: Optional[Crew] = None_CRITIQUE_CREW: Optional[Crew] = Nonedef _get_research_crew() -> Crew:    global _RESEARCH_CREW    if _RESEARCH_CREW is None:        _RESEARCH_CREW = _build_research_crew()    return _RESEARCH_CREWdef _get_writing_crew() -> Crew:    global _WRITING_CREW    if _WRITING_CREW is None:        _WRITING_CREW = _build_writing_crew()    return _WRITING_CREWdef _get_critique_crew() -> Crew:    global _CRITIQUE_CREW    if _CRITIQUE_CREW is None:        _CRITIQUE_CREW = _build_critique_crew()    return _CRITIQUE_CREW_CREW_FACTORIES = {    "research_agent": _get_research_crew,    "writing_agent": _get_writing_crew,    "critique_agent": _get_critique_crew,}async def _kickoff_crew(crew: Crew, task: str) -> str:    """Run a crew off the event loop and return its raw output."""    # `Crew.kickoff` is synchronous and may issue blocking LLM calls; run    # it in a worker thread so the supervisor flow keeps streaming.    output = await asyncio.to_thread(crew.kickoff, inputs={"task": task})    raw = getattr(output, "raw", None)    if raw is None:        raw = str(output)    return str(raw)# ---------------------------------------------------------------------------# Supervisor tool schemas# ---------------------------------------------------------------------------# Each entry below is one "delegation tool" the supervisor LLM can call.# CrewAI's hierarchical Process orchestrates sub-agents internally and# only surfaces the final crew output to the AG-UI bridge, which would# hide every intermediate delegation. Instead, we expose each sub-crew# as a plain OpenAI-compatible tool schema and let the supervisor call# them via litellm; the wrapper flow runs the matching crew on each call# and records a Delegation entry into shared state.def _delegation_tool(name: SubAgentName, description: str) -> dict:    return {        "type": "function",        "function": {            "name": name,            "description": description,            "parameters": {                "type": "object",                "properties": {                    "task": {                        "type": "string",                        "description": (                            "The full task / brief to hand off to the "                            "sub-agent. Include any facts or draft text "                            "the sub-agent will need."                        ),                    }                },                "required": ["task"],            },        },    }RESEARCH_TOOL = _delegation_tool(    "research_agent",    (        "Delegate a research task to the research sub-agent. Use for "        "gathering facts, background, definitions, or statistics. "        "Returns a bulleted list of key facts."    ),)WRITING_TOOL = _delegation_tool(    "writing_agent",    (        "Delegate a drafting task to the writing sub-agent. Use to "        "produce a polished paragraph from a brief and optional facts. "        "Pass relevant facts from prior research inside `task`."    ),)CRITIQUE_TOOL = _delegation_tool(    "critique_agent",    (        "Delegate a critique task to the critique sub-agent. Use to "        "review a draft and surface 2-3 actionable improvements. Pass "        "the draft inside `task`."    ),)DELEGATION_TOOLS = [RESEARCH_TOOL, WRITING_TOOL, CRITIQUE_TOOL]DELEGATION_TOOL_NAMES = {t["function"]["name"] for t in DELEGATION_TOOLS}SUPERVISOR_SYSTEM_PROMPT = (    "You are a supervisor agent that coordinates three specialised "    "sub-agents to produce high-quality deliverables.\n\n"    "Available sub-agents (call them as tools):\n"    "  - research_agent: gathers facts on a topic.\n"    "  - writing_agent: turns facts + a brief into a polished draft.\n"    "  - critique_agent: reviews a draft and suggests improvements.\n\n"    "For most non-trivial user requests, delegate in sequence: "    "research -> write -> critique. Pass the relevant facts/draft "    "through the `task` argument of each tool. Keep your own messages "    "short — explain the plan once, delegate, then return a concise "    "summary once done. The UI shows the user a live log of every "    "sub-agent delegation.")# ---------------------------------------------------------------------------# Supervisor Flow# ---------------------------------------------------------------------------# Hard cap on delegation rounds per turn. Each round = one supervisor# completion + (optionally) one sub-agent kickoff. The expected cycle# is research -> write -> critique = 3 rounds + a final summary, so 6# is generous head-room without leaving the door open for unbounded# loops if the LLM keeps re-delegating._MAX_DELEGATION_ROUNDS = 6class SubagentsFlow(Flow[AgentState]):    """Supervisor flow that delegates to research / writing / critique crews."""    @start()    async def supervise(self) -> None:        # Append-only across turns: prior-turn delegations are preserved        # so follow-up messages don't blow away the user's history.        # Matches every other backend in the cohort (langgraph-python,        # mastra, etc.) — all treat the delegation log as cumulative.        await copilotkit_emit_state(self.state)        for _ in range(_MAX_DELEGATION_ROUNDS):            messages = [                {                    "role": "system",                    "content": SUPERVISOR_SYSTEM_PROMPT,                    "id": str(uuid.uuid4()) + "-system",                },                *self.state.messages,            ]            tools = [                *self.state.copilotkit.actions,                *DELEGATION_TOOLS,            ]            response = await copilotkit_stream(                await acompletion(                    model=f"openai/{_LLM}",                    messages=messages,                    tools=tools,                    parallel_tool_calls=False,                    stream=True,                )            )            message = response.choices[0].message            self.state.messages.append(message)            tool_calls = message.get("tool_calls") or []            if not tool_calls:                # Supervisor has produced a final assistant message;                # we're done.                return            # Iterate ALL tool calls — `parallel_tool_calls=False` is set            # on the LLM call but providers can still emit multiple under            # certain conditions. Indexing `[0]` would silently drop the            # rest, leaving the supervisor hung waiting for results that            # never arrive. Defensive iteration eliminates the silent drop.            saw_frontend_tool = False            for tool_call in tool_calls:                tool_call_id = tool_call["id"]                tool_name = tool_call["function"]["name"]                if tool_name not in DELEGATION_TOOL_NAMES:                    # Frontend-registered action — the AG-UI client owns                    # the round-trip for those. We must NOT append a tool                    # result here (the client will). Mark the loop to exit                    # after processing every tool call so the message                    # thread stays valid for client-side resolution.                    saw_frontend_tool = True                    continue                try:                    args = json.loads(tool_call["function"]["arguments"] or "{}")                except json.JSONDecodeError:                    args = {}                task_text = str(args.get("task") or "").strip()                if not task_text:                    # The model called the tool with no `task`; surface a                    # tool-error message so it can recover on the next round.                    self.state.messages.append(                        {                            "role": "tool",                            "content": (                                "Error: `task` is required and must be a "                                "non-empty string."                            ),                            "tool_call_id": tool_call_id,                        }                    )                    continue                # Append a `running` delegation so the UI shows the                # in-flight call before the crew kickoff completes.                entry_id = str(uuid.uuid4())                self.state.delegations.append(                    Delegation(                        id=entry_id,                        sub_agent=tool_name,  # type: ignore[arg-type]                        task=task_text,                        status="running",                        result="",                    )                )                await copilotkit_emit_state(self.state)                try:                    result_text = await _kickoff_crew(                        _CREW_FACTORIES[tool_name](),                        task_text,                    )                    status: Literal["completed", "failed"] = "completed"                except Exception as exc:  # noqa: BLE001                    # Any failure inside a sub-crew (LLM error, kickoff                    # error, etc.) is recorded on the delegation entry and                    # surfaced to the supervisor as a tool error so it can                    # try a different approach. Scrub to class name only —                    # `repr(exc)` can leak URLs, request IDs, or partial                    # credentials. Operators can correlate via server logs.                    result_text = (                        f"sub-agent call failed: {exc.__class__.__name__} "                        "(see server logs for details)"                    )                    status = "failed"                # Replace the running entry with a completed one.                for i, d in enumerate(self.state.delegations):                    if d.id == entry_id:                        self.state.delegations[i] = Delegation(                            id=entry_id,                            sub_agent=tool_name,  # type: ignore[arg-type]                            task=task_text,                            status=status,                            result=result_text,                        )                        break                self.state.messages.append(                    {                        "role": "tool",                        "content": result_text,                        "tool_call_id": tool_call_id,                    }                )                await copilotkit_emit_state(self.state)            if saw_frontend_tool:                # At least one tool call was a frontend-registered action;                # the AG-UI client handles those round-trips. Stop the                # supervisor loop and let the client respond on the next turn.                returnsubagents_flow = SubagentsFlow()

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.

subagents.py
from __future__ import annotationsimport asyncioimport jsonimport uuidfrom typing import List, Literal, Optionalfrom crewai import Agent, Crew, Process, Taskfrom crewai.flow.flow import Flow, startfrom litellm import acompletionfrom pydantic import BaseModel, Fieldfrom ag_ui_crewai import CopilotKitState, copilotkit_emit_state, copilotkit_stream# ---------------------------------------------------------------------------# Shared state# ---------------------------------------------------------------------------SubAgentName = Literal["research_agent", "writing_agent", "critique_agent"]class Delegation(BaseModel):    """Shape of one entry in the delegation log.    Mirrors the LangGraph reference 1:1 so the frontend type can be    shared verbatim across runtimes.    """    id: str    sub_agent: SubAgentName    task: str    status: Literal["running", "completed", "failed"]    result: str = ""class AgentState(CopilotKitState):    """Shared state. `delegations` is rendered as a live log in the UI."""    delegations: List[Delegation] = Field(default_factory=list)# ---------------------------------------------------------------------------# Sub-agent crews (each is a real, single-agent CrewAI Crew)# ---------------------------------------------------------------------------_LLM = "gpt-4o-mini"# Each sub-agent is a real, single-agent CrewAI Crew with its own# Agent role/goal/backstory and a single Task. They don't share# memory or tools with the supervisor — the supervisor only sees# the crew's final raw output (returned via `Crew.kickoff(...)`).def _build_research_crew() -> Crew:    researcher = Agent(        role="Researcher",        goal="Produce a concise bulleted list of 3-5 key facts on the topic.",        backstory=(            "You are a research sub-agent. You gather and distil "            "information into short, structured bullets. No preamble."        ),        verbose=False,        allow_delegation=False,    )    research_task = Task(        description=(            "Topic: {task}\n\n"            "Produce a concise bulleted list of 3-5 key facts about the "            "topic. Each bullet ≤ 1 short sentence. No preamble or "            "closing remarks."        ),        expected_output="3-5 short bullets, one per line, prefixed with '- '.",        agent=researcher,    )    return Crew(        agents=[researcher],        tasks=[research_task],        process=Process.sequential,        verbose=False,        chat_llm=_LLM,    )def _build_writing_crew() -> Crew:    writer = Agent(        role="Writer",        goal="Turn a brief and any source facts into a polished single paragraph.",        backstory=(            "You are a writing sub-agent. You take a brief plus optional "            "facts and produce one polished paragraph. Be clear and "            "concrete. No preamble."        ),        verbose=False,        allow_delegation=False,    )    writing_task = Task(        description=(            "Brief and source material:\n{task}\n\n"            "Produce one polished paragraph (3-6 sentences). No "            "headings, no bullet list, no preamble."        ),        expected_output="Exactly one polished paragraph.",        agent=writer,    )    return Crew(        agents=[writer],        tasks=[writing_task],        process=Process.sequential,        verbose=False,        chat_llm=_LLM,    )def _build_critique_crew() -> Crew:    critic = Agent(        role="Editorial Critic",        goal="Give 2-3 crisp, actionable critiques of a draft.",        backstory=(            "You are a critique sub-agent. You read a draft and offer "            "2-3 crisp, actionable improvements. No preamble, no rewrite."        ),        verbose=False,        allow_delegation=False,    )    critique_task = Task(        description=(            "Draft to critique:\n{task}\n\n"            "Provide 2-3 crisp, actionable critiques as short bullets "            "(one critique per bullet). No preamble, no rewrite of the "            "draft itself."        ),        expected_output="2-3 short bullet-point critiques.",        agent=critic,    )    return Crew(        agents=[critic],        tasks=[critique_task],        process=Process.sequential,        verbose=False,        chat_llm=_LLM,    )

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:

  1. Runs the sub-agent synchronously on the supplied task string.
  2. Records the delegation into a delegations slot in shared agent state (so the UI can render a live log).
  3. Returns the sub-agent's final message as a ToolMessage, which the supervisor sees as a normal tool result on its next turn.
subagents.py
from __future__ import annotationsimport asyncioimport jsonimport uuidfrom typing import List, Literal, Optionalfrom crewai import Agent, Crew, Process, Taskfrom crewai.flow.flow import Flow, startfrom litellm import acompletionfrom pydantic import BaseModel, Fieldfrom ag_ui_crewai import CopilotKitState, copilotkit_emit_state, copilotkit_stream# ---------------------------------------------------------------------------# Shared state# ---------------------------------------------------------------------------SubAgentName = Literal["research_agent", "writing_agent", "critique_agent"]class Delegation(BaseModel):    """Shape of one entry in the delegation log.    Mirrors the LangGraph reference 1:1 so the frontend type can be    shared verbatim across runtimes.    """    id: str    sub_agent: SubAgentName    task: str    status: Literal["running", "completed", "failed"]    result: str = ""class AgentState(CopilotKitState):    """Shared state. `delegations` is rendered as a live log in the UI."""    delegations: List[Delegation] = Field(default_factory=list)# ---------------------------------------------------------------------------# Sub-agent crews (each is a real, single-agent CrewAI Crew)# ---------------------------------------------------------------------------_LLM = "gpt-4o-mini"# Each sub-agent is a real, single-agent CrewAI Crew with its own# Agent role/goal/backstory and a single Task. They don't share# memory or tools with the supervisor — the supervisor only sees# the crew's final raw output (returned via `Crew.kickoff(...)`).def _build_research_crew() -> Crew:    researcher = Agent(        role="Researcher",        goal="Produce a concise bulleted list of 3-5 key facts on the topic.",        backstory=(            "You are a research sub-agent. You gather and distil "            "information into short, structured bullets. No preamble."        ),        verbose=False,        allow_delegation=False,    )    research_task = Task(        description=(            "Topic: {task}\n\n"            "Produce a concise bulleted list of 3-5 key facts about the "            "topic. Each bullet ≤ 1 short sentence. No preamble or "            "closing remarks."        ),        expected_output="3-5 short bullets, one per line, prefixed with '- '.",        agent=researcher,    )    return Crew(        agents=[researcher],        tasks=[research_task],        process=Process.sequential,        verbose=False,        chat_llm=_LLM,    )def _build_writing_crew() -> Crew:    writer = Agent(        role="Writer",        goal="Turn a brief and any source facts into a polished single paragraph.",        backstory=(            "You are a writing sub-agent. You take a brief plus optional "            "facts and produce one polished paragraph. Be clear and "            "concrete. No preamble."        ),        verbose=False,        allow_delegation=False,    )    writing_task = Task(        description=(            "Brief and source material:\n{task}\n\n"            "Produce one polished paragraph (3-6 sentences). No "            "headings, no bullet list, no preamble."        ),        expected_output="Exactly one polished paragraph.",        agent=writer,    )    return Crew(        agents=[writer],        tasks=[writing_task],        process=Process.sequential,        verbose=False,        chat_llm=_LLM,    )def _build_critique_crew() -> Crew:    critic = Agent(        role="Editorial Critic",        goal="Give 2-3 crisp, actionable critiques of a draft.",        backstory=(            "You are a critique sub-agent. You read a draft and offer "            "2-3 crisp, actionable improvements. No preamble, no rewrite."        ),        verbose=False,        allow_delegation=False,    )    critique_task = Task(        description=(            "Draft to critique:\n{task}\n\n"            "Provide 2-3 crisp, actionable critiques as short bullets "            "(one critique per bullet). No preamble, no rewrite of the "            "draft itself."        ),        expected_output="2-3 short bullet-point critiques.",        agent=critic,    )    return Crew(        agents=[critic],        tasks=[critique_task],        process=Process.sequential,        verbose=False,        chat_llm=_LLM,    )# Lazy singletons — each Crew is hot once built, so reuse across requests.# Built lazily so import is cheap and aimock-mocked tests don't trigger# any Crew machinery at module load._RESEARCH_CREW: Optional[Crew] = None_WRITING_CREW: Optional[Crew] = None_CRITIQUE_CREW: Optional[Crew] = Nonedef _get_research_crew() -> Crew:    global _RESEARCH_CREW    if _RESEARCH_CREW is None:        _RESEARCH_CREW = _build_research_crew()    return _RESEARCH_CREWdef _get_writing_crew() -> Crew:    global _WRITING_CREW    if _WRITING_CREW is None:        _WRITING_CREW = _build_writing_crew()    return _WRITING_CREWdef _get_critique_crew() -> Crew:    global _CRITIQUE_CREW    if _CRITIQUE_CREW is None:        _CRITIQUE_CREW = _build_critique_crew()    return _CRITIQUE_CREW_CREW_FACTORIES = {    "research_agent": _get_research_crew,    "writing_agent": _get_writing_crew,    "critique_agent": _get_critique_crew,}async def _kickoff_crew(crew: Crew, task: str) -> str:    """Run a crew off the event loop and return its raw output."""    # `Crew.kickoff` is synchronous and may issue blocking LLM calls; run    # it in a worker thread so the supervisor flow keeps streaming.    output = await asyncio.to_thread(crew.kickoff, inputs={"task": task})    raw = getattr(output, "raw", None)    if raw is None:        raw = str(output)    return str(raw)# ---------------------------------------------------------------------------# Supervisor tool schemas# ---------------------------------------------------------------------------# Each entry below is one "delegation tool" the supervisor LLM can call.# CrewAI's hierarchical Process orchestrates sub-agents internally and# only surfaces the final crew output to the AG-UI bridge, which would# hide every intermediate delegation. Instead, we expose each sub-crew# as a plain OpenAI-compatible tool schema and let the supervisor call# them via litellm; the wrapper flow runs the matching crew on each call# and records a Delegation entry into shared state.def _delegation_tool(name: SubAgentName, description: str) -> dict:    return {        "type": "function",        "function": {            "name": name,            "description": description,            "parameters": {                "type": "object",                "properties": {                    "task": {                        "type": "string",                        "description": (                            "The full task / brief to hand off to the "                            "sub-agent. Include any facts or draft text "                            "the sub-agent will need."                        ),                    }                },                "required": ["task"],            },        },    }RESEARCH_TOOL = _delegation_tool(    "research_agent",    (        "Delegate a research task to the research sub-agent. Use for "        "gathering facts, background, definitions, or statistics. "        "Returns a bulleted list of key facts."    ),)WRITING_TOOL = _delegation_tool(    "writing_agent",    (        "Delegate a drafting task to the writing sub-agent. Use to "        "produce a polished paragraph from a brief and optional facts. "        "Pass relevant facts from prior research inside `task`."    ),)CRITIQUE_TOOL = _delegation_tool(    "critique_agent",    (        "Delegate a critique task to the critique sub-agent. Use to "        "review a draft and surface 2-3 actionable improvements. Pass "        "the draft inside `task`."    ),)DELEGATION_TOOLS = [RESEARCH_TOOL, WRITING_TOOL, CRITIQUE_TOOL]DELEGATION_TOOL_NAMES = {t["function"]["name"] for t in DELEGATION_TOOLS}

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: [UseAgentUpdate.OnStateChanged, UseAgentUpdate.OnRunStatusChanged] }), read agent.state.delegations, and render one card per entry.

delegation-log.tsx
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-crew it kicks off            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                    data-testid="delegation-status"                    className={`text-[10px] uppercase tracking-[0.12em] font-semibold ${STATUS_STYLE[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>                {d.result ? (                  <div className="text-sm text-[#010507] whitespace-pre-wrap bg-white rounded-lg p-2.5 border border-[#E9E9EF]">                    {d.result}                  </div>                ) : (                  <div className="text-xs italic text-[#838389]">                    Sub-agent running...                  </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.

  • Shared State — the channel that makes the delegation log live.
  • State streaming — stream individual sub-agent outputs token-by-token inside each log entry.