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:
- The parent model calls
subagentwith anagentandtask. - PAI creates a child thread under the parent thread.
- PAI sends the task message to the selected child agent.
- The child agent runs with its own instructions, tools, runtime context, and history.
- 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:
parentThreadIdto the parent thread id;categoryfrom the selected agent config, and nothing when that config omits it.categoryis 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 byparentThreadId, not by category;listVisibilityto"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
agentthat 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 withThreadAgentMismatchError(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, anderror— 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.