Calling Agents
Run another registered agent from inside a tool, with the caller inherited.
A tool can run another agent as part of the current run. The delegated run inherits the caller's storage partition, links to the calling thread and run for tracing, and is cancelled with it.
const draftReply = defineTool({
id: "support.draft-reply.v1",
description: "Draft a reply to a customer request.",
inputSchema: z.object({ request: z.string() }),
outputSchema: z.object({ draft: z.string() }),
execute: async (ctx) => {
const run = await ctx.agents.run(billingAgent, ctx.input.request);
return { draft: run.text };
},
});Every agent a tool delegates to must be registered in createPai({ agents }).
A hand-written tool holds its delegate in a closure, so an unregistered one is
only detected when the tool first delegates. defineSubagentTool declares its
roster where createPai can read it, and so fails at construction instead —
that earlier error is the one advantage it retains.
Two Shapes Of Delegation
This is the general form: your code decides which agent runs. The model asks for a drafted reply and never sees the choice, so a tool can classify, branch, fan out, or run a critic pass — whatever the work needs.
Subagents are the other shape: defineSubagentTool builds a
tool whose input schema is an enum of your roster keys, so the model picks a
specialist by name. It is built on these same two methods and has no privileges of its
own — anything it does, a hand-written tool can do.
Reach for a subagent tool when the model should route. Reach for ctx.agents.run
directly when the application should.
Running Several At Once
Delegated runs are ordinary promises, so fanning out is Promise.all:
const [research, review] = await Promise.all([
ctx.agents.run(researchAgent, ctx.input.topic),
ctx.agents.run(reviewAgent, ctx.input.plan),
]);Use run here, not start. start returns before there is a result, so a
fan-out built on it gives you thread ids and nothing to combine.
What The Delegate Inherits
| Behaviour | |
|---|---|
| Storage partition | Always the caller's. A delegated run cannot reach another tenant's data. |
| Identity | The caller's, unless you supply one. |
| Client data | Not inherited — the delegate declares its own schema. |
| Thread | A fresh thread linked by parentThreadId, hidden from ordinary listings. |
| Cancellation | Cancelled with the calling run. A run from start is not, unless you ask. |
| Tools, context | Nothing crosses over. The delegate builds its own runtimeContext. |
Resolving with the delegate's assistant text, run throws if the delegated
run failed or was cancelled, so a tool that cannot proceed without an answer does
not have to check for one. start resolves before there is an outcome to report,
so it delivers one through onComplete instead.
A tool that can degrade catches AgentRunFailedError, which carries the same
facts onComplete would have received — no message parsing:
try {
const run = await ctx.agents.run(researchAgent, ctx.input.question);
return { answer: run.text };
} catch (error) {
if (error instanceof AgentRunFailedError && error.status === "cancelled") {
return ctx.fail({ message: "Research was cancelled. Try again." });
}
throw error;
}Running As Someone Else
Omit identity and the delegate runs as the caller. Supply it to run as someone
else — as a value, or as a function of the caller.
Prefer the value form. Declare what you need in the tool's contextSchema,
project it once at the agent boundary, and build the delegate's identity from
typed fields:
const delegateToInspector = defineTool({
id: "projects.inspect.v1",
description: "Inspect one project.",
contextSchema: z.object({ tenantId: z.string() }),
inputSchema: z.object({ projectId: z.string() }),
outputSchema: z.object({ findings: z.string() }),
execute: async (ctx) => {
const run = await ctx.agents.run(inspectorAgent, {
task: `Inspect project ${ctx.input.projectId}`,
identity: { ...ctx.context, projectId: ctx.input.projectId },
});
return { findings: run.text };
},
});
// registered on the agent, which is where the identity is known
{ tool: delegateToInspector, mapContext: ({ identity }) => ({ tenantId: identity.tenantId }) }No cast, and the delegate's identity is checked against its declared schema. The task is checked against the same child contract: structured task metadata accepts the child's sparse message-metadata input and rejects unknown fields or invalid values before the delegated run starts.
If code conditionally selects between agents with different contracts, narrow
that selection before supplying any task metadata, identity, or clientData
override. Until then, delegation accepts text or metadata-free structured tasks
and inherited identity only; this keeps the task and overrides correlated with
the agent that actually runs.
The deriver, when you do not want to wire context
identity also accepts a function. The runtime hands it the caller at the moment
the delegate's identity is derived — a tool still never gets ambient access to
its own caller, which is the point:
const run = await ctx.agents.run(inspectorAgent, {
task: ctx.input.request,
identity: (caller) => ({
...(caller.identity as ProjectIdentity),
projectId,
}),
});Note the cast. caller.identity is unknown, because a tool definition is
portable across agents and cannot know one application's identity shape — the
same reason mapContext exists. That cast is the cost of skipping
contextSchema, and it is unchecked at compile time: get the shape wrong and
the mistake surfaces when the delegate parses its own identity schema, as a
runtime validation failure rather than a silent one.
Use the deriver when wiring a context projection is not worth it. Use the value form when the identity matters, which for tenancy is usually.
Either way, the thread locks
Supplying an identity or client data marks the delegated thread as not
directly executable, because the run no longer represents the caller. A client
can still watch it, but send on it fails with ThreadDirectExecutionError.
Continuing that thread later leaves the lock in place, whatever the continuing
call passes.
Labelling The Delegated Thread
await ctx.agents.run(researchAgent, {
task: ctx.input.question,
thread: {
title: `Research: ${ctx.input.question.slice(0, 60)}`,
category: "research",
visibility: "hidden",
},
});category is an application-owned tag the runtime never reads back. Delegated
threads are identified by parentThreadId, and visibility defaults to
"hidden" so they stay out of ordinary sidebars. Pass thread.id to continue an
existing delegated thread instead of creating one.
Starting Without Waiting
run waits. start returns as soon as the delegated run is admitted, and
delivers the outcome through onComplete instead:
const { threadId } = await ctx.agents.start(reviewAgent, {
task: ctx.input.plan,
onComplete: (finished) =>
finished.status === "completed"
? { notification: `Review ready: ${finished.text}` }
: { notification: `Review ${finished.status}.` },
});onComplete returns a notification that wakes the calling agent with the
outcome, or nothing to leave it undisturbed.
These are two methods rather than one method with a background flag so each
has an honest return type: run resolves with text, start cannot. A single
call whose flag decided the shape would type as having text even when it does
not — a flag widened to boolean in a variable is not something the compiler
can follow.
A started run deliberately outlives the turn that launched it, since the
launching run ends as soon as start resolves. Set cancelWithParent: true
when it should stop with the caller instead.
Testing A Delegating Tool
runTool from @pai/test-utils stubs delegation rather than running a real
agent. Omit the stub and a delegating tool fails loudly instead of silently
delegating to nothing.
const handle = await runTool(draftReply, {
input: { request: "I was charged twice." },
caller: { identity: { userId: "user_1" }, clientData: undefined },
agents: () => "Here is your refund.",
});
expect(handle.agentRuns).toEqual([
{ agent: "billing-agent", task: "I was charged twice.", mode: "run" },
]);handle.agentRuns records every delegated run with its overrides already
resolved, so an assertion is on the identity the delegate would receive rather
than on a closure.