PAIPAI

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 partitionAlways the caller's. A delegated run cannot reach another tenant's data.
IdentityThe caller's, unless you supply one.
Client dataNot inherited — the delegate declares its own schema.
ThreadA fresh thread linked by parentThreadId, hidden from ordinary listings.
CancellationCancelled with the calling run. A run from start is not, unless you ask.
Tools, contextNothing 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.

On this page