App Registry
Serve multiple PAI agents from one app receiver, client, and React provider.
createPai is the composition root for apps with more than one agent. Register
every agent once, then use the registry id everywhere: routing, thread
ownership, HTTP clients, and React bindings.
import { createPai, type InferPaiContract } from "@pai/core";
import { createInMemoryThreadStore } from "@pai/storage-inmemory";
import { support, research, reviewer } from "./agents";
export const pai = createPai({
agents: {
support,
researcher: research,
reviewer,
},
storage: createInMemoryThreadStore(),
scopeKey: (identity) => identity.workspaceId,
});
export type AppPai = InferPaiContract<typeof pai>;Mount one receiver:
import { createPaiHonoReceiver } from "@pai/hono";
app.route(
"/api/pai",
createPaiHonoReceiver({
pai,
resolveIdentity: () => ({
identity: { id: "user_123", workspaceId: "workspace_123" },
}),
}),
);The returned receiver's close() hook closes the mounted HTTP receivers and
the pai app registry. createPai.close() is idempotent, so explicit app
shutdown can also call it safely.
Use one client and one React provider:
import { createPaiHttpClient } from "@pai/client-http";
import { createPaiReact } from "@pai/react";
import type { AppPai } from "../server/pai";
export const paiClient = createPaiHttpClient<AppPai>({ url: "/api/pai" });
export const Pai = createPaiReact<AppPai>();
export const Support = Pai.agent("support");
export const Researcher = Pai.agent("researcher");
export const Reviewer = Pai.agent("reviewer");
export function App({ threadId }: { threadId: string }) {
return (
<Pai.Provider client={paiClient}>
<Support.ThreadProvider threadId={threadId}>
<Transcript binding={Support} />
</Support.ThreadProvider>
</Pai.Provider>
);
}For subagent UI, read the child agentId and threadId from the parent tool
payload, then render that child through its own binding:
function ResearcherTranscript() {
return <Transcript chat={Researcher.useChat()} />;
}
function ReviewerTranscript() {
return <Transcript chat={Reviewer.useChat()} />;
}
function ChildTranscript({
agentId,
threadId,
}: {
agentId: "researcher" | "reviewer";
threadId: string;
}) {
if (agentId === "researcher") {
return (
<Researcher.ThreadProvider threadId={threadId} readOnly>
<ResearcherTranscript />
</Researcher.ThreadProvider>
);
}
return (
<Reviewer.ThreadProvider threadId={threadId} readOnly>
<ReviewerTranscript />
</Reviewer.ThreadProvider>
);
}
function SubagentCard({ tool }: { tool: ToolRenderProps<SupportContract, "subagent"> }) {
const ref = Support.useSubagent(tool);
if (!ref) return null;
const childAgentId =
ref.agentId === "researcher" || ref.agentId === "reviewer"
? ref.agentId
: null;
if (!childAgentId) return null;
return (
<ChildTranscript agentId={childAgentId} threadId={ref.threadId} />
);
}Keep each binding-specific hook in a component that calls it statically, as
above. Shared Transcript display code can be generic over AgentContract,
iterate chat.messages and each message's native parts, and pass the owning
{ message, part } pair to the package-root Tool component. This preserves
one message representation across every agent binding.
If agents use different identity schemas, pass a resolver map keyed by registry
id instead of one shared resolveIdentity function.