Page actions

ChatSubagentCardComponent

ChatSubagentCardComponent renders one delegated subagent as an expandable card: its name, the tool call that spawned it, a status pill, a message count, and — once expanded — the subagent's own transcript. The running example is a trip planner whose orchestrator delegates research, booking, and itinerary work to a child graph, and this page walks the files that make each dispatch show up as a card.

Selector: chat-subagent-card

Import:

import { ChatSubagentCardComponent } from '@threadplane/chat';

What the demo does

The Run tab shows the prebuilt <chat> composition beside a sidebar that lists the pipeline: orchestrator, then a research subagent, a booking subagent, and an itinerary subagent. One welcome suggestion, "Plan a trip from LAX to JFK", starts the run.

Send it and the orchestrator calls one task tool three times, in that order. Each call renders in the transcript as a subagent card rather than as a generic tool-call chip. A card opens while its subagent is running, streams that subagent's answer inside itself, and collapses to a one-line summary when the dispatch completes. The cards stay in the transcript afterwards, so the finished run reads as three collapsed dispatches followed by the orchestrator's own summary of the trip.

How it is built

A child graph the orchestrator can invoke, a task tool that invokes it, one line of adapter configuration that says task means delegation, a helper that binds the child stream to its tool call, the orchestrator's own tool-calling loop, and a component that renders the standard <chat> composition carry the feature together. Open the Code tab to read them in place.

The subagent subgraph

Each specialist is the same compiled child graph, parameterized by a subagent_type that selects its system prompt. The child keeps its own state schema, so the task description arrives as a field rather than as a chat message.

graph.py — the child graph
class SubagentState(TypedDict):
    """Child-graph state. `subagent_type` selects the system prompt."""
    messages: Annotated[list, add_messages]
    subagent_type: str
    task_description: str
 
 
async def _subagent_node(state: SubagentState) -> dict:
    """Focused subagent: a single role-prompted LLM call. Kept to ONE LLM call
    (no within-subagent tool loop) so each subagent's request has a unique,
    stable discriminator (its role-specific task_description) — this lets the
    aimock e2e replay match it deterministically. The within-subagent tool
    calling is exercised by the dedicated tool-calls cap; here the focus is
    subagent orchestration + the inline subagent card. The returned message
    streams under this subgraph's `tools:<call_id>` namespace, which the
    @threadplane/langgraph SubagentTracker matches to surface the card."""
    subagent_type = state["subagent_type"]
    task_description = state["task_description"]
    system_prompt = _SUBAGENT_PROMPTS.get(subagent_type, _ITINERARY_PROMPT)
 
    llm = ChatOpenAI(model="gpt-5-mini", streaming=True)
    response = await llm.ainvoke([
        SystemMessage(content=system_prompt),
        HumanMessage(content=task_description),
    ])
    return {"messages": [response]}
 
 
# Compiled child graph. Invoking it from inside the `task` tool makes LangGraph
# nest its run under a `tools:<uuid>` namespace, where the uuid is a checkpoint
# id assigned independently — nothing on the wire links it to the `call_*`
# tool-call id. The @threadplane/langgraph SubagentTracker matches this
# namespace to the registered `task` dispatch to surface a card.
_subagent_builder = StateGraph(SubagentState)
_subagent_builder.add_node("subagent", _subagent_node)
_subagent_builder.set_entry_point("subagent")
_subagent_builder.add_edge("subagent", END)
subagent_subgraph = _subagent_builder.compile()

Invoking that compiled graph from inside a tool is what makes LangGraph nest its run under a tools:<id> namespace, and everything the card shows comes from that namespaced stream.

The task tool

The tool is an ordinary LangChain tool. It takes the specialist to dispatch and a plain-English description of the work, invokes the child graph, and returns the child's final text to the orchestrator.

graph.py — the delegation tool
@tool
async def task(
    subagent_type: Literal["research", "booking", "itinerary"],
    task_description: str,
    tool_call_id: Annotated[str, InjectedToolCallId] = None,
    config: RunnableConfig = None,
) -> str:
    """Delegate a subtask to a specialized subagent subgraph.
 
    Args:
        subagent_type: Which specialist to dispatch — "research" (airport /
            destination intel), "booking" (flight options between origin and
            destination), or "itinerary" (final trip plan synthesizing research
            + bookings). This label also identifies the subagent in the UI.
        task_description: Plain-English description of what the subagent should
            do (e.g., "Gather info on LAX and JFK airports").
 
    Returns:
        The subagent's final answer as a string.
    """
    _announce_subagent(config, tool_call_id)
    result = await subagent_subgraph.ainvoke(
        {"subagent_type": subagent_type, "task_description": task_description, "messages": []}
    )
    messages = result.get("messages") if isinstance(result, dict) else None
    return _final_text(messages)

subagent_type is a Literal, which matters twice: it constrains what the model may dispatch, and it becomes the card's label.

Binding the child stream to its tool call

Nothing on the wire links the child's tools:<uuid> namespace to the parent's call_* tool-call id — the uuid is a checkpoint id assigned independently. Inside the tool body both halves are known, so the example emits them together as one custom event.

graph.py — announcing the binding
def _announce_subagent(config, tool_call_id: str) -> None:
    """Bind this tool call's child stream to its tool-call id, for the UI.
 
    LangGraph streams the child under a `tools:<uuid>` namespace whose uuid is
    a checkpoint id — nothing on the wire links it to the `call_*` id, so a
    frontend showing per-subagent progress would otherwise have to guess.
    Inside the tool body both halves are known; emit them as one custom event
    that `@threadplane/langgraph` recognizes.
 
    Inlined per the cockpit standalone rule — the canonical helper is
    `threadplane.middleware.langgraph.announce_subagent`.
    """
    if not tool_call_id:
        return
    meta = dict((config or {}).get("metadata") or {})
    namespace = meta.get("checkpoint_ns")
    if not namespace:
        return
    try:
        from langgraph.config import get_stream_writer
 
        get_stream_writer()(
            {
                "type": "threadplane.subagent_binding",
                "namespace": namespace,
                "tool_call_id": tool_call_id,
            }
        )
    except Exception:
        pass

@threadplane/langgraph consumes that event as protocol chatter rather than forwarding it as application data, and replays any chunks that streamed before the binding arrived.

Note: The binding is authoritative, the fallback is a heuristic

Without the announcement the adapter falls back to attributing the first unmapped pending or running child, which is correct only while one dispatch is outstanding at a time; with no description argument to match on, the ladder lands on its positional rung. Announcing the binding is what makes parallel fan-out safe. The canonical helper is threadplane.middleware.langgraph.announce_subagent; the example inlines it because each example stands alone.

The orchestrator

The parent is a plain tool-calling loop: a model bound to the single task tool, a ToolNode that runs it, and a conditional edge back to the model until no tool calls remain.

graph.py — the parent graph
def build_subagents_graph():
    """Orchestrator LLM with a single `task` tool that dispatches to subagent functions."""
    llm = ChatOpenAI(model="gpt-5-mini", streaming=True).bind_tools([task])
 
    async def orchestrator(state: MessagesState) -> dict:
        system_prompt = (PROMPTS_DIR / "subagents.md").read_text()
        messages = [SystemMessage(content=system_prompt)] + state["messages"]
        response = await llm.ainvoke(messages)
        return {"messages": [response]}
 
    def should_continue(state: MessagesState) -> str:
        last = state["messages"][-1]
        if hasattr(last, "tool_calls") and last.tool_calls:
            return "tools"
        return END
 
    graph = StateGraph(MessagesState)
    graph.add_node("orchestrator", orchestrator)
    graph.add_node("tools", ToolNode([task]))
    graph.add_node("generate_title", generate_title)
    graph.set_entry_point("orchestrator")
    graph.add_conditional_edges("orchestrator", should_continue, {"tools": "tools", END: "generate_title"})
    graph.add_edge("tools", "orchestrator")
    graph.add_edge("generate_title", END)
    return graph.compile()

The system prompt tells the orchestrator to dispatch research, then booking, then itinerary, which is why the demo produces three cards in a stable order.

Telling the adapter that task means delegation

provideAgent() registers the agent once for the whole application, and it is the only provider the <chat> composition requires. The one subagent-specific line is subagentToolNames. The default is already ['task'], so this example states explicitly what it would otherwise inherit.

The example resolves its connection details at runtime from the host that serves the demo, which is why its factory reads them rather than hard-coding them; your own application passes apiUrl and assistantId directly.

app.config.ts
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideAgent(() => {
      const connection = injectCockpitRuntimeConnection();
      if (connection.adapter !== 'langgraph') {
        throw new Error('incompatible runtime');
      }
      return {
        apiUrl: connection.apiUrl,
        assistantId: connection.assistantId,
        clientOptions: connection.clientOptions,
        // Treat `task` tool calls as subagent dispatches: the SubagentTracker
        // registers them and matches the child subgraph's tools:<id> namespace,
        // so agent.subagents() populates and the inline subagent card renders.
        subagentToolNames: ['task'],
      };
    }),
  ],
};

A tool call is registered as a subagent only when its name is in subagentToolNames and its arguments carry a plausible subagent_type; that argument then becomes Subagent.name.

Where the card appears

The component is small on purpose. It renders <chat>, a welcome suggestion, and a static sidebar note — and nothing about subagents at all. Subagent cards appear because <chat-tool-calls>, inside the <chat> composition, cross-references each tool call against agent.subagents(): a call that spawned a subagent renders as a standalone <chat-subagent-card> instead of a tool-call card, and never groups with adjacent calls.

subagents.component.ts — the template
<example-chat-layout sidebarWidth="20rem">
  <chat main [agent]="agent" class="flex-1 min-w-0">
    <div chatWelcomeSuggestions>
      @for (s of suggestions; track s.value) {
        <chat-welcome-suggestion
          [label]="s.label"
          [value]="s.value"
          [description]="s.description"
          (selected)="send($event)"
        />
      }
    </div>
  </chat>
  <div sidebar class="panel">
    <h3 class="cap">Agent Pipeline</h3>
    <ol class="info-list">
      <li>Orchestrator</li>
      <li>Research subagent</li>
      <li>Booking subagent</li>
      <li>Itinerary subagent</li>
    </ol>
  </div>
</example-chat-layout>

That cross-reference works because the agent the demo provides exposes subagents() for <chat-tool-calls> to read.

subagents.component.ts — the agent
protected readonly agent = injectAgent();
 
protected readonly suggestions = SUGGESTIONS;
 
protected send(text: string): void {
  void this.agent.submit({ message: text });
}

Basic usage

Rendering a card directly needs only the subagent:

<chat-subagent-card [subagent]="subagentRef" />

API

Inputs

InputTypeDefaultDescription
subagentSubagentRequiredThe subagent to display

The component exposes no outputs.

Subagent

The Subagent type comes from @threadplane/chat and is what every adapter maps its own subagent state onto:

PropertyTypeDescription
toolCallIdstringThe tool call that spawned this subagent
namestring | undefinedOptional human-readable name. The card falls back to the literal Subagent when it is absent
statusSignal<'pending' | 'running' | 'complete' | 'error'>Current execution status
messagesSignal<Message[]>Messages produced by the subagent
toolCallsSignal<ToolCall[]> | undefinedThe subagent's own tool calls, referenced by each message's toolCallIds. Optional — adapters that do not surface subagent tool calls omit it, and the card defaults to []
stateSignal<Record<string, unknown>>Arbitrary subagent state exposed by the runtime

In the running example name is the subagent_type argument (research, booking, itinerary), state carries the child's state values plus the tool result once it lands, and toolCalls is absent, because the LangGraph adapter does not surface a child's own tool calls.

Note: A dispatch becomes visible when its child stream starts

The adapter registers a subagent as pending the moment the orchestrator's tool call arrives, and hides pending entries from agent.subagents(). The entry turns running when its child stream is bound, and complete (or error) when the tool result message lands.

What the card renders

The header is the <chat-trace> toggle button. It shows a chevron reflecting the expanded state, the name (or the literal Subagent), the toolCallId in a monospace face, a color-coded status pill, and the message count as "N message(s)".

Expansion

Expansion is delegated to <chat-trace>: the card expands automatically while the status is running or error, and otherwise stays collapsed. Clicking the header sets a manual override that wins from then on. Re-entering running or error from another state clears that override, so a card that is dispatched again opens again.

Transcript

When expanded, the card renders the subagent's entire message list, not just the latest message. For each message, in order:

  • Any reasoning text, as a muted italic line
  • The message content, rendered through <chat-streaming-md> so it streams as markdown
  • A <chat-tool-call-card> for each tool call the message references and the toolCalls signal resolves

Status pill colors

The pill is styled through a data-status attribute and CSS selectors. The exported statusColor() helper returns the equivalent inline style string and is retained for existing consumers.

StatusBackgroundText color
pending--tplane-chat-surface-alt--tplane-chat-text-muted
running--tplane-chat-warning-bg--tplane-chat-warning-text
complete(none)--tplane-chat-success
error--tplane-chat-error-bg--tplane-chat-error-text

Cards outside the transcript

The example needs no explicit markup because the <chat> composition places each card inline. To render cards somewhere else — a sidebar tray, for instance — use the ChatSubagentsComponent primitive, which iterates the agent's subagents and drops the ones that already reached complete or error:

<chat-subagents [agent]="agent">
  <ng-template let-subagent>
    <chat-subagent-card [subagent]="subagent" />
  </ng-template>
</chat-subagents>

The template is optional. Without one, <chat-subagents> renders a <chat-subagent-card> for each active subagent itself.

Styling

The card reads these CSS custom properties:

VariableApplied to
--tplane-chat-textSubagent name
--tplane-chat-text-mutedTool call id, message count, reasoning line, pending pill text
--tplane-chat-font-monoTool call id
--tplane-chat-separatorDivider between successive transcript messages
--tplane-chat-surface-altpending pill background
--tplane-chat-warning-bg / --tplane-chat-warning-textrunning status pill
--tplane-chat-successcomplete status pill
--tplane-chat-error-bg / --tplane-chat-error-texterror status pill

The card draws no background, border, or radius of its own. The only border anywhere in this pairing is the left rule on the expanded body, drawn by <chat-trace>; any surrounding chrome comes from the host that places the card.

Accessibility

The header is a real <button> and exposes aria-expanded reflecting the current state, so the card is operable from the keyboard and announced as a disclosure.

What's Next

Looking for something specific?