PAIPAI

Subagents

Call specialist agents through a normal tool.

Subagents are specialist PAI agents that a parent agent can call through a normal backend tool.

defineSubagentTool is the model-chosen shape of delegation: the roster keys become an enum in the tool's input schema, so the model picks a specialist by name. It is built on ctx.agents.run and ctx.agents.start and has no privileges of its own — when the application should pick the agent, call that directly instead.

The parent model does not need a separate subagents surface. It sees one subagent tool with a small input:

type SubagentInput = {
  agent: "researcher" | "reviewer";
  task: string;
  threadId?: ThreadId;
  description?: string;
  runInBackground?: boolean;
};

agent is selected from the tool's configured agent registry. task is the self-contained prompt sent to the selected child agent. threadId is optional; when provided, PAI appends the task to that existing child thread instead of creating a fresh child thread. runInBackground is only available when the tool or at least one configured child agent is declared background-capable.

Use a subagent tool when the delegated work needs its own model loop, tools, runtime context, thread history, or audit trail. Use a backend tool when the work is a single deterministic operation. Use a command when application code, not the model, needs to trigger a trusted server action.

Define Specialist Agents

Each specialist is an ordinary PAI agent. It can have its own model, instructions, tools, runtime context, lifecycle hooks, and tests.

import { openai } from "@ai-sdk/openai";
import { defineAgent } from "@pai/core";
import { z } from "zod";
import { openDocument, searchDocuments } from "./research-tools";

const workspaceIdentity = z.object({
  workspaceId: z.string(),
});

export const researchAgent = defineAgent({
  name: "market-researcher",

  identity: workspaceIdentity,

  model: openai("gpt-4.1-mini"),

  instructions: `
You are a focused research agent. Search the workspace, inspect relevant
documents, and return concise findings with source references.
`,

  tools: {
    searchDocuments,
    openDocument,
  },
});

The child agent owns its own capabilities. Parent tools are not automatically available to the child, and child tools are not automatically available to the parent.

Create A Subagent Tool

defineSubagentTool() creates a normal PAI tool. Its agents map is the allowed list of specialists the model can choose from.

import { defineSubagentTool } from "@pai/core";
import { researchAgent } from "./research-agent";
import { reviewAgent } from "./review-agent";

export const subagent = defineSubagentTool({
  id: "support.delegate-to-specialist.v1",
  description:
    "Run a bounded task with a specialist subagent and return its result.",

  agents: {
    researcher: {
      agent: researchAgent,
      description:
        "Use for workspace research, document search, and cited findings.",
      allowBackground: true,
      thread: {
        category: "research",
        visibility: "hidden",
        title: ({ task }) => `Research: ${task.slice(0, 80)}`,
      },
    },

    reviewer: {
      agent: reviewAgent,
      description:
        "Use for implementation review, risk analysis, and test-gap checks.",
      thread: {
        category: "review",
        visibility: "hidden",
      },
    },
  },
});

The registered keys become the model-facing agent enum. The descriptions are the routing contract: they tell the parent model when to choose each specialist.

By default the task prompt sent to the child is the model-provided task. Configure task when the app needs to frame the child prompt consistently.

export const subagent = defineSubagentTool({
  id: "support.delegate-to-specialist.v1",
  // ...
  agents: {
    researcher: {
      agent: researchAgent,
      description: "Use for workspace research and cited findings.",
      allowBackground: true,
      task: ({ task }) => ({
        text: [
          "Research this request for the current workspace.",
          "Return a concise answer with source references.",
          "",
          task,
        ].join("\n"),
      }),
    },
    reviewer: {
      agent: reviewAgent,
      description: "Use for implementation review and risk analysis.",
    },
  },
});

Keep this framing explicit. The parent model chooses what to delegate, but the application controls the prompt wrapper, metadata, scope mapping, and background policy.

Keep each roster entry bound to one concrete child contract. If an agent value is still an unresolved union, text or metadata-free task mapping remains valid, but any task metadata, identity, or clientData mapping requires narrowing the child first. This prevents an override for one candidate from reaching a different runtime-selected agent.

Register The Tool

Register the subagent tool like any other backend tool:

import { defineAgent, type InferAgentContract } from "@pai/core";
import { createTask } from "./tools";
import { subagent } from "./subagent-tool";

export const proposalAgent = defineAgent({
  name: "proposal-drafter",

  identity: workspaceIdentity,

  model: openai("gpt-4.1-mini"),

  instructions: `
Draft procurement recommendations. Use the subagent tool when you need focused
research or review before making a claim.
`,

  tools: {
    createTask,
    subagent,
  },
});

export type ProposalAgentContract = InferAgentContract<typeof proposalAgent>;

There is no separate defineAgent({ subagents }) map. A subagent is a tool that happens to run another agent.

What The Parent Model Sees

Foreground delegation behaves like a normal blocking tool call:

  1. The parent model calls subagent with an agent and task.
  2. PAI creates a child thread under the parent thread.
  3. PAI sends the task message to the selected child agent.
  4. The child agent runs with its own instructions, tools, runtime context, and history.
  5. PAI returns a compact result to the parent model.

The parent model does not receive the whole child transcript by default. The child transcript remains in the child thread. This keeps the parent context small and preserves a separate audit trail for delegated work.

The default foreground output is:

type SubagentResult = {
  status: "completed";
  agent: string;
  agentId?: string;
  threadId: ThreadId;
  runId: RunId;
  text: string;
};

The parent model receives the child threadId so it can call the same subagent again for follow-up work. Runtime metadata such as usage, progress, and trace links belongs in tool data and thread state.

Child Threads

Every subagent call is recorded as a child thread:

// The fields delegation sets. `ThreadSummary` also carries the ordinary
// listing fields — agent, status, version, metadata, timestamps.
type ThreadSummary = {
  threadId: ThreadId;
  parentThreadId?: ThreadId;
  category?: string;
  listVisibility: "visible" | "hidden";
  title?: string;
};

PAI sets:

  • parentThreadId to the parent thread id;
  • category from the selected agent config, and nothing when that config omits it. category is an application-owned tag with no reserved namespace, so PAI does not stamp one of its own — a framework default would collide with whatever your app means by the same value. Child threads are identified by parentThreadId, not by category;
  • listVisibility to "hidden" unless the selected agent opts into visible child threads;
  • metadata linking the parent run, parent tool call, child run, and selected agent.

Normal sidebar lists should omit hidden child threads. If a specialist opts into visible child threads, UIs can list those children by parent:

const children = await client.threads.list({
  parentThreadId: parentThreadId,
  category: "research",
});

Hidden children are still available through parent tool state while a subagent is running. Trusted server code can also include hidden children:

const admin = runtime.admin({ identity });

const children = await admin.threads.list({
  parentThreadId: parentThreadId,
  includeHidden: true,
});

Access Model

Subagent threads follow one rule: scope authorizes access; the recorded agent owns the typed thread contract.

  • Every thread records the agent that responds in it, stamped at creation from the serving runtime's agent.
  • Hydrated reads and watches (thread.state, run.state, thread.watch) require both the authorized identity/scope and the thread's recorded agent. Use the child agent address so its tools, actions, and client-readable run metadata are validated and typed by the correct contract.
  • Lists remain scope-authorized summaries. Multi-agent app runtimes filter ordinary lists to the addressed agent; trusted listings may include hidden children for auditing.
  • Execution (send, regenerate, trigger, resuming a pending action) is authorized by agent match. A runtime rejects execution on a thread owned by a different agent with ThreadAgentMismatchError (HTTP 403).

The parent agent address therefore cannot hydrate or execute a child thread. useSubagent(tool) itself does no I/O; it synchronously derives a child-thread reference from the parent tool payload. To observe and render the transcript, choose the child binding from its agentId (or an explicit app mapping from the roster-key agent), then mount that binding's ThreadProvider with the returned threadId. Direct child execution is allowed only for child threads created without custom identity() or clientData() mappers; mapper-created children reject direct execution with ThreadDirectExecutionError.

// Both reject: the support contract does not own the child thread.
await client.agent("support").thread(childThreadId).refresh();
await client.agent("support").thread(childThreadId).send("...");
// ThreadAgentMismatchError

// Read through the contract that owns the thread.
await client.agent("researcher").thread(childThreadId).refresh();

Progress

Subagent progress is written to the parent tool state. The exact child transcript stays in the child thread, but the parent thread has enough state for rendering:

type SubagentToolData = {
  agent: string;
  agentId?: string;
  threadId: ThreadId;
  runId?: RunId;
  status: "running" | "completed" | "failed" | "cancelled";
  title?: string;
  resultPreview?: string;
  error?: {
    message: string;
  };
};

Custom renderers can use that data to show delegated work inline, link to the child thread, or expose pending child actions without copying the child messages into the parent transcript.

Rendering

@pai/react treats subagents as ordinary agents. useSubagent(tool) derives a child reference from the subagent tool payload. The child transcript is rendered by selecting the child agent binding from subagent.agentId or an explicit roster mapping, mounting that binding's ThreadProvider, and reusing the same transcript component you use for top-level agents.

useSubagent(tool) returns a pure reference to the child thread:

  • agent — the roster key chosen by the parent model;
  • agentId — the app registry id used by clients and React bindings, when the child definition belongs to an app registry;
  • threadId — the child thread to render;
  • status, title, resultPreview, and error — render-friendly summary state from the parent tool data.

agentId is optional because a child definition does not have to belong to the app registry. When it is absent, use an explicit app-owned mapping from the roster key in agent to a binding, or render the summary without mounting a child binding. Do not assume the roster key is also an addressable agent id.

Child tool renderers live on the child binding just like main-agent tool renderers. There is no merged child contract and no special transcript API.

const Pai = createPaiReact<AppPai>();

const ResearcherAgent = Pai.agent("researcher", {
  toolRenderers: {
    searchDocuments: ({ part }) =>
      part.state === "output-available" ? (
        <SearchCard hits={part.output.hits} />
      ) : null,
  },
});

const ReviewerAgent = Pai.agent("reviewer", {
  toolRenderers: {
    auditPlan: ({ part }) =>
      part.state === "output-available" ? (
        <AuditCard blockers={part.output.blockers} />
      ) : null,
  },
});

const supportToolRenderers = {
  subagent: (tool) => <SubagentCard tool={tool} />,
} satisfies ToolRendererMap<AppPai["agents"]["support"]>;

const SupportAgent = Pai.agent("support", {
  toolRenderers: supportToolRenderers,
});

function Transcript<TContract extends AgentContract>({
  messages,
}: {
  messages: PaiMessage<TContract>[];
}) {
  return messages.map((message) => (
    <article key={message.id}>
      {message.parts.map((part, index) => {
        if (part.type === "text") {
          return (
            <Markdown key={`${message.id}:${index}`}>{part.text}</Markdown>
          );
        }
        if (isPaiToolPart(part)) {
          return (
            <Tool
              key={part.toolCallId}
              message={message}
              part={part}
            />
          );
        }
        return null;
      })}
    </article>
  ));
}

function ResearcherTranscript() {
  const chat = ResearcherAgent.useChat();
  return <Transcript messages={chat.messages} />;
}

function ReviewerTranscript() {
  const chat = ReviewerAgent.useChat();
  return <Transcript messages={chat.messages} />;
}

function ChildTranscript({
  agentId,
  threadId,
}: {
  agentId: "researcher" | "reviewer";
  threadId: string;
}) {
  if (agentId === "researcher") {
    return (
      <ResearcherAgent.ThreadProvider threadId={threadId} readOnly>
        <ResearcherTranscript />
      </ResearcherAgent.ThreadProvider>
    );
  }

  return (
    <ReviewerAgent.ThreadProvider threadId={threadId} readOnly>
      <ReviewerTranscript />
    </ReviewerAgent.ThreadProvider>
  );
}

function SubagentCard({ tool }: { tool: ToolRenderProps<AppPai["agents"]["support"], "subagent"> }) {
  const [open, setOpen] = useState(false);
  const subagent = SupportAgent.useSubagent(tool);
  const canRenderChild =
    subagent?.agentId === "researcher" || subagent?.agentId === "reviewer";

  if (!subagent) return null;

  return (
    <section>
      <button onClick={() => setOpen((value) => !value)}>
        {subagent.agent} — {subagent.status}
      </button>
      {open && canRenderChild ? (
        <ChildTranscript agentId={subagent.agentId} threadId={subagent.threadId} />
      ) : null}
    </section>
  );
}

For wider apps, make shared transcript components generic over AgentContract and keep each binding's hooks in a component that calls them statically. Pass the owning message and narrowed native tool part together to the package-root Tool. Renderer maps stay beside the bindings that own them. Rendering a child thread then looks exactly like rendering the main thread.

const sharedResearchRenderers = {
  searchDocuments: ({ part }) =>
    part.state === "output-available" ? (
      <SearchCard hits={part.output.hits} />
    ) : null,
} satisfies ToolRendererMap<AppPai["agents"]["researcher"]>;

function SupportTranscript() {
  const chat = SupportAgent.useChat();
  return <Transcript messages={chat.messages} />;
}

function App({ threadId }: { threadId: string }) {
  return (
    <Pai.Provider client={paiClient}>
      <SupportAgent.ThreadProvider threadId={threadId}>
        <SupportTranscript />
      </SupportAgent.ThreadProvider>
    </Pai.Provider>
  );
}

Use the roster key for display and for narrowing shared child-tool names. Validate agentId (or resolve an app-owned roster mapping) to select the child binding and route. Inline rendering then uses that ordinary child binding; it does not need a separate subagent-only transcript API.

The tool name ("subagent" above) is whatever you registered the tool under; the hooks read the child roster off that tool, so any registration name works and one agent can register several subagent tools with different rosters.

Multiple Delegations

There is no separate fan-out API in the first design. If the parent model needs three specialists, it can call the delegation tool three times.

This keeps each delegated task observable as a normal tool call, avoids a second batching surface, and lets the runtime use the same scheduling and cancellation rules as other tools.

Resuming A Child Thread

Pass a previous subagent result's threadId back into the tool when follow-up work should continue the same child transcript:

await subagent({
  agent: "researcher",
  threadId: "thread_child_123",
  task: "Drill into support-load risk and add concrete mitigations.",
});

The selected agent still controls task framing, identity mapping, client data, and thread metadata. The explicit input threadId wins over a configured thread.id() callback for that call.

Backgroundable Tools

Background execution is configured when the delegation tool or a specific child entry is created. The model cannot background a child entry that was not declared background-capable.

export const subagent = defineSubagentTool({
  id: "research.delegate-in-background.v1",
  description:
    "Run a bounded task with a specialist subagent and return its result.",

  agents: {
    researcher: {
      agent: researchAgent,
      description: "Use for workspace research and cited findings.",
    },
  },

  allowBackground: {
    onComplete: (completion) =>
      completion.status === "completed"
        ? {
            notification: `${completion.agent} finished: ${completion.text.slice(0, 200)}`,
            hidden: false,
            data: {
              type: "subagent.completed",
              agent: completion.agent,
              task: completion.task,
              threadId: completion.threadId,
            },
          }
        : {
            notification: `${completion.agent} ${completion.status}`,
            data: {
              type: "subagent.failed",
              agent: completion.agent,
              task: completion.task,
              threadId: completion.threadId,
            },
          },
  },
});

When the tool or any selected agent entry allows backgrounding, the generated tool input includes runInBackground?: boolean.

type BackgroundableSubagentInput = {
  agent: "researcher";
  task: string;
  threadId?: ThreadId;
  description?: string;
  runInBackground?: boolean;
};

When the parent model sets runInBackground: true, PAI starts the child run and returns a receipt immediately:

type BackgroundSubagentReceipt = {
  status: "started";
  agent: string;
  agentId?: string;
  threadId: ThreadId;
  runId: RunId;
};

The child thread keeps running after the parent step moves on. When the child settles, PAI calls onComplete and uses the returned trigger input to wake the parent thread:

await parentThread.trigger({
  notification: "researcher finished: Found three relevant benchmarks.",
  data: {
    type: "subagent.completed",
    agent: "researcher",
    threadId: "thread_child_123",
  },
});

Use background delegation for monitoring, long-running research, or work that should notify the parent thread later instead of blocking the current model step.

If neither the tool nor any child entry allows backgrounding, runInBackground is not part of the tool schema. When some children allow backgrounding and others do not, it is, so the model can ask to background one that cannot be: the tool call then fails before starting child work, telling the model to call it again without runInBackground rather than leaving it to guess that the subagent is unusable.

Identity, Scope, And Context

Subagents inherit the parent identity by default, so a child runs in the same storage partition. If a child agent declares a different identity schema, map it in that agent's registry entry:

export const subagent = defineSubagentTool({
  id: "projects.delegate-to-inspector.v1",
  // ...
  agents: {
    projectInspector: {
      agent: projectInspectorAgent,
      description: "Use for questions about a single project.",
      identity: ({ caller, task }) => ({
        ...(caller.identity as ProjectIdentity),
        projectId: extractProjectId(task),
      }),
    },
  },
});

A delegated run always executes in the caller's storage partition, whatever identity it carries, so mapping cannot reach another tenant's data. What it does change is the identity the child agent's own code sees — use it to adapt the caller to a child that declares a different identity schema, not as an authorization boundary.

Declaring identity() or clientData() also marks the child thread as not directly executable, because the run no longer represents the caller.

The child agent builds its own runtimeContext from its own identity and clientData. Parent runtimeContext is not passed directly to the child. A registry entry can project selected parent context into the child task or child clientData.

export const subagent = defineSubagentTool({
  id: "research.delegate-with-workspace-context.v1",
  contextSchema: z.object({
    workspaceName: z.string(),
  }),

  agents: {
    researcher: {
      agent: researchAgent,
      description: "Use for workspace research and cited findings.",
      task: ({ task, context }) => ({
        text: `Research for ${context.workspaceName}:\n\n${task}`,
      }),
    },
  },
});

export const proposalAgent = defineAgent({
  // ...
  runtimeContext: async ({ scope }) => ({
    workspace: await loadWorkspace(identity.workspaceId),
  }),
  tools: {
    subagent: {
      tool: subagent,
      mapContext: ({ runtimeContext }) => ({
        workspaceName: runtimeContext.workspace.name,
      }),
    },
  },
});

Client-provided tools are not forwarded to child agents by default. A child agent should declare the tools it can safely use.

Pending Actions

Child agents keep their own pending actions on their own child threads. A foreground delegation waits for the child run to settle, so if the child enters waiting the parent tool call remains active until that child action is resolved. A background delegation does not block the parent run; the child pending action remains visible through the child thread.

The first implementation does not project child pending actions into the parent thread as parent pending actions. Parent UIs should link to the child thread or render the child status from subagent ToolData.

Cancellation And Failure

Stopping a parent run cancels active foreground subagent work. The child run is marked cancelled and the parent tool call is cancelled.

Background child work is independent after the receipt is returned. Configure cancelWithParent: true under allowBackground when the child should be stopped if the parent thread is stopped before the background task settles.

export const subagent = defineSubagentTool({
  id: "research.delegate-in-background.v1",
  // ...
  allowBackground: {
    cancelWithParent: true,
    onComplete: (completion) => ({
      notification:
        completion.status === "completed"
          ? `${completion.agent} finished: ${completion.text}`
          : `${completion.agent} ${completion.status}`,
    }),
  },
});

By default, a failed foreground child run fails the delegation tool call. Parent lifecycle hooks can observe the failure through beforeToolCall and afterToolResult with tool.source === "subagent". Use those hooks for fallback policy, redaction, or converting known child failures into model-visible outputs.

Idempotency

By default, each subagent tool call creates a fresh child thread. This preserves the audit trail for the exact model decision that delegated the work.

Use thread.id() when repeated parent retries should target the same child thread id:

export const subagent = defineSubagentTool({
  id: "research.delegate-with-stable-thread.v1",
  agents: {
    researcher: {
      agent: researchAgent,
      description: "Use for workspace research and cited findings.",
      thread: {
        id: ({ parent, task }) =>
          `research:${parent.threadId}:${stableHash(task)}`,
      },
    },
  },
});

When a configured child thread already exists, the current implementation appends a new child user message and queues the child run if needed. Prefer fresh child threads unless reuse is needed for idempotent retries or a long-lived specialist workspace. For conversational follow-ups, prefer the model-facing threadId input returned by the previous subagent result.

API Shape

The public surface is a tool factory plus per-agent registry entries.

type MaybePromise<T> = T | Promise<T>;

type SubagentToolMapperInput<TContext = unknown> = {
  task: string;
  threadId?: ThreadId;
  description?: string;
  context: TContext;
  parent: {
    threadId: ThreadId;
    runId: RunId;
    toolCallId: string;
    toolName: string;
  };
  signal: AbortSignal;
};

/** Mapper input for the two derivations that need the delegating caller. */
type SubagentToolCallerMapperInput<TContext = unknown> =
  SubagentToolMapperInput<TContext> & {
    caller: { identity: unknown; clientData: unknown };
  };

type SubagentToolBackgroundCompletionInput<TContext = unknown> = {
  agent: string;
  agentId?: string;
  task: string;
  description?: string;
  context: TContext;
  threadId: ThreadId;
  runId: RunId;
} & (
  | { status: "completed"; text: string }
  | { status: "failed" | "cancelled"; error?: { message: string } }
);

type SubagentToolBackgroundConfig<TContext = unknown> = {
  cancelWithParent?: boolean;
  onComplete?(
    input: SubagentToolBackgroundCompletionInput<TContext>,
  ): MaybePromise<ToolTriggerInput | null | undefined>;
};

/** Illustrative private helper used by the signature below. */
type IsUnion<TValue, TWhole = TValue> = TValue extends TWhole
  ? [TWhole] extends [TValue]
    ? false
    : true
  : never;

type SubagentToolAgentConfig<
  TChildContract extends AgentContract = AgentContract,
  TContext = unknown,
  TChildRuntimeContext = unknown,
> = {
  agent: AgentDefinition<TChildContract, TChildRuntimeContext>;
  description: string;
  allowBackground?: boolean | SubagentToolBackgroundConfig<TContext>;
  task?(
    input: SubagentToolMapperInput<TContext>,
  ): MaybePromise<SendInput<TChildContract>>;
  identity?(
    input: SubagentToolCallerMapperInput<TContext>,
  ): MaybePromise<
    IsUnion<TChildContract> extends true
      ? never
      : JsonCompatible<IdentityOf<TChildContract>>
  >;
  clientData?(
    input: SubagentToolCallerMapperInput<TContext>,
  ): MaybePromise<
    IsUnion<TChildContract> extends true
      ? never
      : JsonCompatible<ClientDataOf<TChildContract>>
  >;
  thread?: {
    id?(input: SubagentToolMapperInput<TContext>): MaybePromise<ThreadId>;
    category?: string;
    visibility?: "visible" | "hidden";
    title?: string | ((input: SubagentToolMapperInput<TContext>) => string);
    metadata?(
      input: SubagentToolMapperInput<TContext>,
    ): MaybePromise<JsonObject>;
  };
};

type DefineSubagentToolConfig<
  TContextSchema extends z.ZodType | undefined = undefined,
  TContext = TContextSchema extends z.ZodType
    ? z.output<TContextSchema>
    : unknown,
  TAgents extends Record<
    string,
    SubagentToolAgentConfig<any, TContext>
  > = Record<string, SubagentToolAgentConfig<any, TContext>>,
> = {
  id: string;
  description?: string;
  contextSchema?: TContextSchema;
  allowBackground?: boolean | SubagentToolBackgroundConfig<TContext>;
  agents: TAgents;
};

DefineSubagentToolConfig exposes id as string, while defineSubagentTool() requires the value at its call site to remain one non-empty literal without surrounding whitespace. Pass the config inline. If you extract it, preserve the property with id: "support.delegate.v1" as const; annotating the object as DefineSubagentToolConfig (or using satisfies without as const) widens the property and fails the literal-id check.

Reuse an id only for the same wire spec; a different child roster or incompatible payload shape needs a different id. Namespace app and package ids as described under tool definition ids.

Most apps only need the tool id and agents, with an agent and description for each roster entry. The factory supplies tuned default delegation guidance, so override the tool-level description only for app-specific instructions. Add tool-level or entry-level allowBackground only when the app is prepared for completion triggers and background child-thread state.

Testing

Test each specialist agent by itself first. It is just an agent.

Then test the parent at the thread boundary:

const runtime = createAgentRuntime({
  agent: proposalAgent,
  storage,
  realtime,
  scopeKey: (identity) => identity.workspaceId,
});

const client = runtime.client({
  identity: { userId: "user_1", workspaceId: "workspace_123" },
});

const run = await client.thread("parent").send({
  text: "Draft a recommendation and use a subagent for market benchmark research first.",
});

await run.wait();

const children = await runtime
  .admin({ identity: { userId: "user_1", workspaceId: "workspace_123" } })
  .threads.list({
    parentThreadId: "parent",
    includeHidden: true,
  });

expect(children.items).toContainEqual(
  expect.objectContaining({
    category: "research",
    listVisibility: "hidden",
  }),
);

For parent-agent tests that do not need the child runtime, stub the delegation tool output the same way you would stub a backend tool result. For integration tests, keep the child agent real and assert public behavior: parent result, child thread metadata, pending actions, cancellation, background completion trigger, and progress state.

On this page