Meet Frontfriend.Your brand. Your rules. Your agents, connected.Explore what’s new

Subagent

A subagent run with nested tool calls, task details, and a final report.

Interactive preview

The shared preview uses React Base. The source below uses Base UI. Each example runs with local data.

Installation

Complete project setup with react/base, then install these components:

pnpm exec frontfriend download trace code-block badge icon button message bubble -f react --primitive base

Keep preliminary output in the running state until the subagent returns its final result. Nested steps use stable IDs and controlled disclosures. The task and model badges describe the run, and the duration appears after its title.

Usage

Complete example

import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
  Trace,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceDescription,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { Badge } from "@/components/ui/badge";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Button } from "@/components/ui/button";
import * as React from "react";
type Phase = "complete" | "running" | "error" | "pending";
function useRun(initial: Phase) {
  const [phase, setPhase] = React.useState(initial);
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(
    undefined,
  );
  React.useEffect(() => {
    clearTimeout(timer.current);
    setPhase(initial);
  }, [initial]);
  React.useEffect(() => () => clearTimeout(timer.current), []);
  function replay() {
    clearTimeout(timer.current);
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setPhase("complete");
      return;
    }
    setPhase("running");
    timer.current = setTimeout(() => setPhase("complete"), 3600);
  }
  return { phase, replay };
}
type AgentStep = {
  id: string;
  title: string;
  status?: "running" | "error" | "pending";
  output?: string;
  children?: AgentStep[];
  report?: string;
  task?: string;
  duration?: string;
  model?: string;
  tool?: string;
};
const frame: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
function AgentRow({
  step,
  open,
  onOpen,
}: {
  step: AgentStep;
  open: Record<string, boolean>;
  onOpen: (id: string, value: boolean) => void;
}) {
  const line = (
    <TraceLine status={step.status}>
      <TraceIcon>
        <Icon
          name={
            step.status === "running"
              ? "LoaderCircle"
              : step.status === "error"
                ? "CircleAlert"
                : step.status === "pending"
                  ? "CircleDashed"
                  : "CircleCheck"
          }
        />
      </TraceIcon>
      <TraceTitle>{step.title}</TraceTitle>
      {step.duration && <TraceDescription>{step.duration}</TraceDescription>}
    </TraceLine>
  );
  if (!step.children && !step.output) return line;
  return (
    <TraceCollapsible
      open={open[step.id] ?? Boolean(step.children)}
      onOpenChange={(value) => onOpen(step.id, value)}
    >
      <TraceCollapsibleTrigger>{line}</TraceCollapsibleTrigger>
      <TraceCollapsibleContent keepMounted>
        <div style={{ display: "grid", gap: 12 }}>
          {step.task && (
            <>
              <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
                <Badge variant="secondary">{step.model}</Badge>
                <Badge variant="outline">{step.tool}</Badge>
              </div>
              <p>{step.task}</p>
            </>
          )}
          {step.children && (
            <Trace>
              {step.children.map((child) => (
                <AgentRow
                  key={child.id}
                  step={child}
                  open={open}
                  onOpen={onOpen}
                />
              ))}
            </Trace>
          )}
          {step.output && (
            <CodeBlock
              size="sm"
              variant={step.status === "error" ? "destructive" : "default"}
            >
              <CodeBlockContent wrap style={{ maxHeight: 256 }}>
                {step.output}
              </CodeBlockContent>
            </CodeBlock>
          )}
          {step.report && <p>{step.report}</p>}
        </div>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  );
}
export default function AiSubagentExample() {
  const { phase, replay: run } = useRun("complete");
  const [open, setOpen] = React.useState<Record<string, boolean>>({});
  function onOpen(id: string, value: boolean) {
    setOpen((current) => ({ ...current, [id]: value }));
  }
  function replay() {
    setOpen({});
    run();
  }
  const step: AgentStep = {
    id: "research",
    title:
      phase === "complete"
        ? "Ran the research subagent"
        : phase === "running"
          ? "Running the research subagent"
          : phase === "pending"
            ? "Research subagent queued"
            : "Research subagent failed",
    status: phase === "complete" ? undefined : phase,
    task: "Find out why image optimization reruns on every deploy.",
    duration: phase === "complete" ? "48s" : undefined,
    model: "Research model",
    tool: "runCommand",
    children: [
      {
        id: "find",
        title:
          phase === "pending"
            ? "Run rg cacheKey scripts"
            : "Ran rg cacheKey scripts",
        status: phase === "pending" ? "pending" : undefined,
        output:
          phase === "pending"
            ? undefined
            : "scripts/optimize-images.ts:14: const cacheKey = process.env.COMMIT_SHA",
      },
      {
        id: "read",
        title:
          phase === "complete"
            ? "Read scripts/optimize-images.ts"
            : phase === "error"
              ? "Failed to read scripts/optimize-images.ts"
              : phase === "pending"
                ? "Read scripts/optimize-images.ts"
                : "Reading scripts/optimize-images.ts",
        status: phase === "complete" ? undefined : phase,
        output:
          phase === "complete"
            ? 'const cacheDir = ".next/cache/images"\nconst cacheKey = process.env.COMMIT_SHA\nconst cached = await restoreCache(cacheDir, cacheKey)'
            : phase === "error"
              ? "The file could not be read."
              : undefined,
      },
      {
        id: "count",
        title:
          phase === "complete"
            ? "Counted public/images"
            : "Count public/images",
        status: phase === "complete" ? undefined : "pending",
        output: phase === "complete" ? "1240" : undefined,
      },
    ],
    report:
      phase === "complete"
        ? "The cache is keyed on the commit SHA, so each deployment optimizes all 1,240 images again."
        : undefined,
  };
  return (
    <div style={frame}>
      <Message align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>
              Why does image optimization rerun on every deploy?
            </BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message>
        <MessageContent style={{ gap: 16, minWidth: 0 }}>
          <Trace>
            <AgentRow step={step} open={open} onOpen={onOpen} />
          </Trace>
          {phase === "complete" && (
            <p>
              Key the cache on a hash of public/images, so unchanged images will
              be skipped.
            </p>
          )}
        </MessageContent>
      </Message>
      <div>
        <Button
          size="sm"
          variant="outline"
          onClick={replay}
          disabled={phase === "running"}
        >
          Replay
        </Button>
      </div>
    </div>
  );
}

AI SDK

The research tool returns a nested UIMessage. Its output.parts render recursively, including nested tool calls and text. An output-available part with preliminary: true still runs while the chat streams. It becomes a failure if streaming stops before final output. Final output replaces the preliminary message.

The basic example runs without a provider. For the AI SDK adapter example only, install the SDK types:

pnpm add ai

Pass a message from your chat client to the adapter. The supplied message does not call a provider.

import type { UIMessage } from "ai";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Badge } from "@/components/ui/badge";
import * as React from "react";
const frame: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
type SdkAgentMessage = UIMessage<
  unknown,
  {},
  {
    runCommand: {
      input: {
        command: string;
      };
      output: {
        stdout: string;
        exitCode: number;
      };
    };
    research: {
      input: {
        task: string;
        model?: string;
      };
      output: SdkAgentMessage;
    };
  }
>;
type SdkCommandMessage = UIMessage<
  unknown,
  {},
  {
    runCommand: {
      input: {
        command: string;
      };
      output: {
        stdout: string;
        exitCode: number;
      };
    };
  }
>;
type SdkCommandPart = Extract<
  SdkCommandMessage["parts"][number],
  {
    type: "tool-runCommand";
  }
>;
function sdkCommandState(part: SdkCommandPart, streaming: boolean) {
  const command = part.input?.command ?? "command";
  const denied =
    part.state === "output-denied" ||
    (part.state === "approval-responded" && !part.approval.approved);
  const finished = part.state === "output-available" && !part.preliminary;
  const waiting = part.state === "approval-requested";
  const approved =
    part.state === "approval-responded" && part.approval.approved;
  const failed =
    part.state === "output-error" ||
    (finished && part.output.exitCode !== 0) ||
    (!denied && !finished && !waiting && !approved && !streaming);
  const status = denied
    ? undefined
    : waiting
      ? "pending"
      : failed
        ? "error"
        : finished
          ? undefined
          : "running";
  const title = `${denied ? "Denied" : waiting ? "Waiting to run" : failed ? "Failed to run" : finished ? "Ran" : "Running"} ${command}`;
  const output =
    part.state === "output-error"
      ? part.errorText
      : part.state === "output-available"
        ? part.output.stdout
        : undefined;
  return { status, title, output } as const;
}
function SdkPanel({
  title,
  status,
  children,
  initialOpen = false,
}: {
  title: string;
  status?: "running" | "pending" | "error";
  children: React.ReactNode;
  initialOpen?: boolean;
}) {
  const [open, setOpen] = React.useState<boolean>();
  const expanded = open ?? initialOpen;
  return (
    <TraceCollapsible open={expanded} onOpenChange={setOpen}>
      <TraceCollapsibleTrigger>
        <TraceLine status={status}>
          <TraceIcon>
            <Icon name={status === "error" ? "CircleAlert" : "Terminal"} />
          </TraceIcon>
          <TraceTitle>{title}</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent keepMounted>{children}</TraceCollapsibleContent>
    </TraceCollapsible>
  );
}
function SdkCommandRow({
  part,
  streaming,
}: {
  part: SdkCommandPart;
  streaming: boolean;
}) {
  const view = sdkCommandState(part, streaming);
  const output = view.output;
  if (output !== undefined)
    return (
      <SdkPanel
        title={view.title}
        status={view.status}
        initialOpen={view.status === "error" || view.status === "running"}
      >
        <CodeBlock
          size="sm"
          variant={view.status === "error" ? "destructive" : "default"}
        >
          <CodeBlockContent wrap style={{ maxHeight: 256 }}>
            {output}
          </CodeBlockContent>
        </CodeBlock>
      </SdkPanel>
    );
  return (
    <TraceLine status={view.status}>
      <TraceIcon>
        <Icon name="Terminal" />
      </TraceIcon>
      <TraceTitle>{view.title}</TraceTitle>
    </TraceLine>
  );
}
function SubagentSdkAdapter({
  message,
  streaming,
}: {
  message: SdkAgentMessage;
  streaming: boolean;
}) {
  return (
    <Trace>
      {message.parts.map((part, index) => {
        if (part.type === "text") return <p key={index}>{part.text}</p>;
        if (part.type === "tool-runCommand")
          return (
            <SdkCommandRow
              key={part.toolCallId}
              part={part}
              streaming={streaming}
            />
          );
        if (part.type !== "tool-research") return null;
        const finished = part.state === "output-available" && !part.preliminary;
        const pending = part.state === "approval-requested";
        const denied =
          part.state === "output-denied" ||
          (part.state === "approval-responded" && !part.approval.approved);
        const failed =
          part.state === "output-error" ||
          (!finished && !pending && !denied && !streaming);
        const title = `${denied ? "Denied" : pending ? "Waiting for" : failed ? "Failed to run" : finished ? "Ran" : "Running"} research subagent`;
        return (
          <SdkPanel
            key={part.toolCallId}
            title={title}
            status={
              pending
                ? "pending"
                : failed
                  ? "error"
                  : !finished && !denied
                    ? "running"
                    : undefined
            }
            initialOpen
          >
            <div style={{ display: "grid", gap: 12 }}>
              {part.input?.model && (
                <div>
                  <Badge variant="secondary">{part.input.model}</Badge>
                </div>
              )}
              <p>{part.input?.task}</p>
              {part.state === "output-error" && (
                <CodeBlock size="sm" variant="destructive">
                  <CodeBlockContent wrap>{part.errorText}</CodeBlockContent>
                </CodeBlock>
              )}
              {part.state === "output-available" && (
                <SubagentSdkAdapter
                  message={part.output}
                  streaming={streaming && part.preliminary === true}
                />
              )}
            </div>
          </SdkPanel>
        );
      })}
    </Trace>
  );
}
const sdkMessage: SdkAgentMessage = {
  id: "research",
  role: "assistant",
  parts: [
    {
      type: "tool-research",
      toolCallId: "research-agent",
      state: "output-available",
      preliminary: true,
      input: {
        task: "Find why image optimization runs on every deploy.",
        model: "Research model",
      },
      output: {
        id: "child",
        role: "assistant",
        parts: [
          {
            type: "tool-runCommand",
            toolCallId: "find",
            state: "output-available",
            input: { command: "rg cacheKey scripts" },
            output: {
              stdout: "scripts/optimize-images.ts:14: process.env.COMMIT_SHA",
              exitCode: 0,
            },
          },
          {
            type: "tool-runCommand",
            toolCallId: "read",
            state: "input-available",
            input: { command: "cat scripts/optimize-images.ts" },
          },
        ],
      },
    },
  ],
};
export default function AiSubagentExample() {
  return (
    <div style={frame}>
      <SubagentSdkAdapter message={sdkMessage} streaming />
    </div>
  );
}

Running

import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import * as React from "react";
const frame: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
function SubagentRunningExample() {
  return (
    <Trace>
      <TraceCollapsible defaultOpen>
        <TraceCollapsibleTrigger>
          <TraceLine status="running">
            <TraceIcon>
              <Icon name="LoaderCircle" />
            </TraceIcon>
            <TraceTitle>Running the research agent</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent keepMounted>
          <Trace>
            <TraceLine>
              <TraceIcon>
                <Icon name="CircleCheck" />
              </TraceIcon>
              <TraceTitle>Ran grep -rn cacheKey scripts</TraceTitle>
            </TraceLine>
            <TraceLine status="running">
              <TraceIcon>
                <Icon name="LoaderCircle" />
              </TraceIcon>
              <TraceTitle>
                Running sed -n 13,15p scripts/optimize-images.ts
              </TraceTitle>
            </TraceLine>
          </Trace>
        </TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
  );
}
export default function AiSubagentExample() {
  return (
    <div style={frame}>
      <SubagentRunningExample />
    </div>
  );
}

Details

import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceLine,
  TraceIcon,
  TraceTitle,
  TraceDescription,
  TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { Badge } from "@/components/ui/badge";
import * as React from "react";
const frame: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
function SubagentDetailsExample() {
  return (
    <Trace>
      <TraceCollapsible defaultOpen>
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <Icon name="CircleCheck" />
            </TraceIcon>
            <TraceTitle>Ran the research agent</TraceTitle>
            <TraceDescription>48s</TraceDescription>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent keepMounted>
          <div
            style={{
              display: "flex",
              flexWrap: "wrap",
              gap: 6,
            }}
          >
            <Badge variant="secondary">anthropic/claude-sonnet-4.5</Badge>
            <Badge variant="outline">runCommand</Badge>
          </div>
          <p>Find out why image optimization reruns on every deploy.</p>
          <Trace>
            <TraceLine>
              <TraceIcon>
                <Icon name="CircleCheck" />
              </TraceIcon>
              <TraceTitle>Ran grep -rn cacheKey scripts</TraceTitle>
            </TraceLine>
            <TraceLine>
              <TraceIcon>
                <Icon name="CircleCheck" />
              </TraceIcon>
              <TraceTitle>
                Ran sed -n 13,15p scripts/optimize-images.ts
              </TraceTitle>
            </TraceLine>
          </Trace>
          <p>The cache is keyed on the commit SHA.</p>
        </TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
  );
}
export default function AiSubagentExample() {
  return (
    <div style={frame}>
      <SubagentDetailsExample />
    </div>
  );
}

Components and API

This composition has no separate component API. Use the APIs for its installed components.

ComponentTarget reference
trace Base UI API
code-block Base UI API
badge Base UI API
icon Base UI API
button Base UI API
message Base UI API
bubble Base UI API