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 Reka UI. Each example runs with local data.

Installation

Complete project setup with vue/reka, then install these components:

pnpm exec frontfriend download trace code-block badge icon button message bubble -f vue --primitive reka

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

Save as SubagentExample.vue.

<script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount } from "vue";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
  TraceDescription,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Badge } from "@/components/ui/badge";
type Phase = "complete" | "running" | "error" | "pending";
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 props = withDefaults(
  defineProps<{
    state?: Phase;
    nested?: boolean;
    steps?: AgentStep[];
    open?: Record<string, boolean>;
  }>(),
  { state: "complete", nested: false, open: () => ({}) },
);
const emit = defineEmits<{ expand: [id: string, value: boolean] }>();
const phase = ref<Phase>(props.state);
let timer: ReturnType<typeof setTimeout> | undefined;
watch(
  () => props.state,
  (value) => {
    clearTimeout(timer);
    phase.value = value;
  },
);
onBeforeUnmount(() => clearTimeout(timer));
function replay() {
  clearTimeout(timer);
  expanded.value = {};
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
    phase.value = "complete";
    return;
  }
  phase.value = "running";
  timer = setTimeout(() => {
    phase.value = "complete";
  }, 3600);
}
const expanded = ref<Record<string, boolean>>({});
function expand(id: string, value: boolean) {
  expanded.value = { ...expanded.value, [id]: value };
}
const steps = computed<AgentStep[]>(() => [
  {
    id: "research",
    title:
      phase.value === "complete"
        ? "Ran the research subagent"
        : phase.value === "running"
          ? "Running the research subagent"
          : phase.value === "pending"
            ? "Research subagent queued"
            : "Research subagent failed",
    status: phase.value === "complete" ? undefined : phase.value,
    task: "Find out why image optimization reruns on every deploy.",
    duration: phase.value === "complete" ? "48s" : undefined,
    model: "Research model",
    tool: "runCommand",
    children: [
      {
        id: "find",
        title:
          phase.value === "pending"
            ? "Run rg cacheKey scripts"
            : "Ran rg cacheKey scripts",
        status: phase.value === "pending" ? "pending" : undefined,
        output:
          phase.value === "pending"
            ? undefined
            : "scripts/optimize-images.ts:14: const cacheKey = process.env.COMMIT_SHA",
      },
      {
        id: "read",
        title:
          phase.value === "complete"
            ? "Read scripts/optimize-images.ts"
            : phase.value === "error"
              ? "Failed to read scripts/optimize-images.ts"
              : phase.value === "pending"
                ? "Read scripts/optimize-images.ts"
                : "Reading scripts/optimize-images.ts",
        status: phase.value === "complete" ? undefined : phase.value,
        output:
          phase.value === "complete"
            ? 'const cacheDir = ".nuxt/cache/images"\nconst cacheKey = process.env.COMMIT_SHA\nconst cached = await restoreCache(cacheDir, cacheKey)'
            : phase.value === "error"
              ? "The file could not be read."
              : undefined,
      },
      {
        id: "count",
        title:
          phase.value === "complete"
            ? "Counted public/images"
            : "Count public/images",
        status: phase.value === "complete" ? undefined : "pending",
        output: phase.value === "complete" ? "1240" : undefined,
      },
    ],
    report:
      phase.value === "complete"
        ? "The cache is keyed on the commit SHA, so each deployment optimizes all 1,240 images again."
        : undefined,
  },
]);
function icon(step: AgentStep) {
  return step.status === "running"
    ? "LoaderCircle"
    : step.status === "error"
      ? "CircleAlert"
      : step.status === "pending"
        ? "CircleDashed"
        : "CircleCheck";
}
</script>

<template>
  <Trace v-if="nested">
    <template v-for="step in props.steps" :key="step.id">
      <TraceCollapsible
        :unmount-on-hide="false"
        v-if="step.children || step.output"
        :open="open[step.id] ?? Boolean(step.children)"
        @update:open="emit('expand', step.id, $event)"
      >
        <TraceCollapsibleTrigger>
          <TraceLine :status="step.status">
            <TraceIcon>
              <Icon :name="icon(step)" />
            </TraceIcon>
            <TraceTitle>{{ step.title }}</TraceTitle>
            <TraceDescription v-if="step.duration">
              {{ step.duration }}
            </TraceDescription>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <div style="display: grid; gap: 12px">
            <template v-if="step.task">
              <div style="display: flex; flex-wrap: wrap; gap: 6px">
                <Badge variant="secondary">{{ step.model }}</Badge>
                <Badge variant="outline">{{ step.tool }}</Badge>
              </div>
              <p>{{ step.task }}</p>
            </template>
            <SubagentExample
              v-if="step.children"
              :steps="step.children"
              nested
              :open="open"
              @expand="(id, value) => emit('expand', id, value)"
            />
            <CodeBlock
              v-if="step.output"
              size="sm"
              :variant="step.status === 'error' ? 'destructive' : 'default'"
            >
              <CodeBlockContent wrap style="max-height: 256px">
                {{ step.output }}
              </CodeBlockContent>
            </CodeBlock>
            <p v-if="step.report">{{ step.report }}</p>
          </div>
        </TraceCollapsibleContent>
      </TraceCollapsible>
      <template v-else>
        <TraceLine :status="step.status">
          <TraceIcon>
            <Icon :name="icon(step)" />
          </TraceIcon>
          <TraceTitle>{{ step.title }}</TraceTitle>
          <TraceDescription v-if="step.duration">
            {{ step.duration }}
          </TraceDescription>
        </TraceLine>
      </template>
    </template>
  </Trace>
  <div
    v-else
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      min-height: 288px;
      margin-inline: auto;
      display: flex;
      flex-direction: column;
      gap: 16px;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <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: 16px; min-width: 0">
        <SubagentExample
          :steps="steps"
          nested
          :open="expanded"
          @expand="expand"
        />
        <p v-if="phase === 'complete'">
          Key the cache on a hash of public/images, so unchanged images will be
          skipped.
        </p>
      </MessageContent>
    </Message>
    <div>
      <Button
        size="sm"
        variant="outline"
        :disabled="phase === 'running'"
        @click="replay"
      >
        Replay
      </Button>
    </div>
  </div>
</template>

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.

Save as AiSubagentAdapter.vue.

<script setup lang="ts">
import type { UIMessage } from "ai";
import { computed, ref } from "vue";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceCollapsibleContent,
  TraceLine,
  TraceTitle,
  TraceIcon,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Icon } from "@/components/ui/icon";
import { Badge } from "@/components/ui/badge";
type SdkCommandMessage = UIMessage<
  unknown,
  {},
  {
    runCommand: {
      input: { command: string };
      output: { stdout: string; exitCode: number };
    };
  }
>;
type SdkCommandPart = Extract<
  SdkCommandMessage["parts"][number],
  { type: "tool-runCommand" }
>;
function commandState(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 pending = 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 && !pending && !approved && !streaming);
  const status = denied
    ? undefined
    : pending
      ? "pending"
      : failed
        ? "error"
        : finished
          ? undefined
          : "running";
  return {
    status,
    title: `${denied ? "Denied" : pending ? "Waiting to run" : failed ? "Failed to run" : finished ? "Ran" : "Running"} ${command}`,
    output:
      part.state === "output-error"
        ? part.errorText
        : part.state === "output-available"
          ? part.output.stdout
          : undefined,
  } as const;
}
type SdkAgentMessage = UIMessage<
  unknown,
  {},
  {
    runCommand: {
      input: { command: string };
      output: { stdout: string; exitCode: number };
    };
    research: {
      input: { task: string; model?: string };
      output: SdkAgentMessage;
    };
  }
>;
type AgentPart = Extract<
  SdkAgentMessage["parts"][number],
  { type: "tool-research" }
>;

const props = withDefaults(
  defineProps<{
    message: SdkAgentMessage;
    streaming: boolean;
    nested?: boolean;
  }>(),
  { nested: false },
);
function agentState(part: AgentPart) {
  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 && !props.streaming);
  return {
    title: `${denied ? "Denied" : pending ? "Waiting for" : failed ? "Failed to run" : finished ? "Ran" : "Running"} research subagent`,
    status: pending
      ? "pending"
      : failed
        ? "error"
        : !finished && !denied
          ? "running"
          : undefined,
  } as const;
}
const open = ref<Record<string, boolean>>({});
function setOpen(id: string, value: boolean) {
  open.value = { ...open.value, [id]: value };
}
</script>
<template>
  <div
    :style="
      nested
        ? { display: 'grid', gap: '12px' }
        : {
            width: '100%',
            maxWidth: '384px',
            minWidth: 0,
            marginInline: 'auto',
            display: 'grid',
            gap: '16px',
            paddingBlock: '24px',
            fontSize: 'var(--text-sm)',
            lineHeight: 1.5,
          }
    "
  >
    <Trace>
      <template
        v-for="(part, index) in message.parts"
        :key="'toolCallId' in part ? part.toolCallId : index"
      >
        <p v-if="part.type === 'text'">{{ part.text }}</p>
        <template v-else-if="part.type === 'tool-runCommand'">
          <TraceCollapsible
            :unmount-on-hide="false"
            v-if="commandState(part, streaming).output !== undefined"
            :open="
              open[part.toolCallId] ??
              commandState(part, streaming).status === 'error'
            "
            @update:open="setOpen(part.toolCallId, $event)"
          >
            <TraceCollapsibleTrigger>
              <TraceLine :status="commandState(part, streaming).status">
                <TraceIcon>
                  <Icon name="Terminal" />
                </TraceIcon>
                <TraceTitle>
                  {{ commandState(part, streaming).title }}
                </TraceTitle>
              </TraceLine>
            </TraceCollapsibleTrigger>
            <TraceCollapsibleContent>
              <CodeBlock size="sm">
                <CodeBlockContent wrap>
                  {{ commandState(part, streaming).output }}
                </CodeBlockContent>
              </CodeBlock>
            </TraceCollapsibleContent>
          </TraceCollapsible>
          <TraceLine v-else :status="commandState(part, streaming).status">
            <TraceIcon>
              <Icon name="Terminal" />
            </TraceIcon>
            <TraceTitle>{{ commandState(part, streaming).title }}</TraceTitle>
          </TraceLine>
        </template>
        <TraceCollapsible
          :unmount-on-hide="false"
          v-else-if="part.type === 'tool-research'"
          :open="open[part.toolCallId] ?? true"
          @update:open="setOpen(part.toolCallId, $event)"
        >
          <TraceCollapsibleTrigger>
            <TraceLine :status="agentState(part).status">
              <TraceIcon>
                <Icon name="Bot" />
              </TraceIcon>
              <TraceTitle>{{ agentState(part).title }}</TraceTitle>
            </TraceLine>
          </TraceCollapsibleTrigger>
          <TraceCollapsibleContent>
            <div style="display: grid; gap: 12px">
              <div v-if="part.input?.model">
                <Badge variant="secondary">{{ part.input.model }}</Badge>
              </div>
              <p>{{ part.input?.task }}</p>
              <CodeBlock
                v-if="part.state === 'output-error'"
                size="sm"
                variant="destructive"
              >
                <CodeBlockContent wrap>{{ part.errorText }}</CodeBlockContent>
              </CodeBlock>
              <AiSubagentAdapter
                v-if="part.state === 'output-available'"
                :message="part.output"
                :streaming="streaming && part.preliminary === true"
                nested
              />
            </div>
          </TraceCollapsibleContent>
        </TraceCollapsible>
      </template>
    </Trace>
  </div>
</template>

Running

Save as SubagentRunningExample.vue.

<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace";
</script>

<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      margin-inline: auto;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <Trace>
      <TraceCollapsible default-open :unmount-on-hide="false">
        <TraceCollapsibleTrigger>
          <TraceLine status="running">
            <TraceIcon>
              <Icon name="LoaderCircle" />
            </TraceIcon>
            <TraceTitle>Running the research agent</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <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>
  </div>
</template>

Details

Save as SubagentDetailsExample.vue.

<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
import { Badge } from "@/components/ui/badge";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace";
</script>

<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      margin-inline: auto;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <Trace>
      <TraceCollapsible default-open :unmount-on-hide="false">
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <Icon name="CircleCheck" />
            </TraceIcon>
            <TraceTitle>Ran the research agent</TraceTitle>
            <TraceDescription>48s</TraceDescription>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <div>
            <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>
  </div>
</template>

Components and API

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

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