PAIPAI

Codegen

Generate client-safe contract, client, and React binding files.

The normal TypeScript path is import type from the server PAI module. Use codegen when the frontend cannot safely type-import from the server package, or when you want a generated folder that contains the contract type, contract metadata, typed HTTP client factory, and React binding.

Install the CLI:

pnpm add -D @pai/cli

Export an inferred app contract type from the server PAI registry. A tool's stable definition id is independent of the name each agent uses to register it:

// server/pai.ts
import {
  createPai,
  defineAgent,
  defineTool,
  type InferPaiContract,
} from "@pai/core";

const lookupCustomer = defineTool({
  id: "crm.lookup-customer.v1",
  description: "Look up a customer by id",
  inputSchema: lookupCustomerInput,
  outputSchema: lookupCustomerOutput,
  execute: lookupCustomerHandler,
});

export const assistantAgent = defineAgent({
  // ...model, identity, and instructions
  tools: { findCustomer: lookupCustomer },
});

export const salesAgent = defineAgent({
  // ...model, identity, and instructions
  tools: { lookupCustomer },
});

export const pai = createPai({
  agents: { assistant: assistantAgent, sales: salesAgent },
  scopeKey: (identity) => identity.workspaceId,
});
export type AppPai = InferPaiContract<typeof pai>;

Keep the registry graph as closed object literals so codegen can preserve every agent, tool, command, and subagent key. Do not widen these maps to Record<string, ...> before inference: an index signature discards the literal keys, and the CLI rejects it instead of silently generating an incomplete contract.

Generate a client binding folder:

pnpm exec pai generate client \
  --source server/pai.ts \
  --type AppPai \
  --out-dir web/src/generated/pai \
  --name Pai

--type selects the exported server alias to read. --name controls the generated client API stem, so this example emits Pai, PaiContract, PaiToolSpecs, paiContract, and createPaiClient.

The generated folder contains:

FileContents
types.d.tsFlattened runtime-free <name>Contract and id-keyed <name>ToolSpecs types.
contract.tsRuntime contract metadata object.
client.tsTyped HTTP client factory, React root created with the contract object, and per-agent bindings.
index.tsBarrel export for the generated folder.

Use the generated exports from browser code:

// web/src/pai.ts
import {
  AssistantAgent,
  Pai,
  SalesAgent,
  createPaiClient,
  type PaiContract,
  type PaiToolSpecs,
} from "./generated/pai";

export const paiClient = createPaiClient({
  url: "http://localhost:3001/api/pai",
});

export { AssistantAgent, Pai, SalesAgent };
export type { PaiContract, PaiToolSpecs };

Shared tool renderers

Codegen hoists each distinct id-bearing tool wire spec into the generated <name>ToolSpecs map, keyed by the definition's stable id. Agent contracts reference that spec separately from the name under which each agent registers it. This makes one renderer catalog reusable when several agents expose the same definition under different names.

Type the id-keyed catalog with ToolSpecRendererMap, then derive an agent's name-keyed ToolRendererMap from generated runtime metadata:

import {
  deriveToolRegistry,
  type ToolSpecRendererMap,
} from "@pai/react";
import {
  Pai,
  paiContract,
  type PaiToolSpecs,
} from "./generated/pai";

const toolCatalog = {
  "crm.lookup-customer.v1": ({ part }) => {
    if (part.state === "output-available") {
      return <CustomerCard customer={part.output} />;
    }
    return <CustomerCardSkeleton />;
  },
} satisfies ToolSpecRendererMap<PaiToolSpecs>;

export const assistantToolRenderers = deriveToolRegistry(
  paiContract.agents.assistant.tools,
  toolCatalog,
);

export const salesToolRenderers = deriveToolRegistry(
  paiContract.agents.sales.tools,
  toolCatalog,
);

The catalog key is the stable id, not either agent's registration name. The derived maps use the names each agent expects:

// Equivalent inferred shapes:
// assistantToolRenderers = { findCustomer: toolCatalog["crm.lookup-customer.v1"] }
// salesToolRenderers = { lookupCustomer: toolCatalog["crm.lookup-customer.v1"] }

Configure each derived map as that binding's default renderer map:

export const Assistant = Pai.agent("assistant", {
  toolRenderers: assistantToolRenderers,
});

export const Sales = Pai.agent("sales", {
  toolRenderers: salesToolRenderers,
});

Pai.agent() returns the existing generated binding when one already exists; providing options configures its agent-wide defaults. Configure a binding only once. For a renderer override local to one view, pass the derived map to that binding's ThreadProvider.toolRenderers instead.

Definition ids identify wire specs, not registration names or implementation objects. Aliases and separate implementations of the same spec reuse an id. Codegen rejects two incompatible specs that claim the same id; use a new or versioned id for an incompatible schema revision when old generated clients or persisted conversations can still coexist.

Tool spec renderer API

The shared-renderer types are exported from @pai/react:

APIPurpose
ToolSpecRenderProps<TSpec>Renderer props whose native part input, output, state, and ToolData plus action sidecar payloads come from one generated spec. The dynamic part.toolName remains a string because each agent may register that spec under a different name; action methods return Promise<unknown> because no full agent contract is available.
ToolSpecRenderFn<TSpec>A React renderer function receiving ToolSpecRenderProps<TSpec>.
ToolSpecRendererMap<TSpecs>An optional catalog entry for each stable id in a generated <name>ToolSpecs map. The key must equal that spec's literal id.
DerivedToolRegistry<TAgentTools, TCatalog>The inferred name-keyed result type returned by deriveToolRegistry(). Wrapper libraries may name it explicitly; application code can normally rely on inference.
deriveToolRegistry(agentTools, catalog)Re-keys catalog entries from stable ids to one agent's registration names using generated runtime metadata.

Catalog entries are optional so an application or feature package can render only the specs it owns. Use satisfies ToolSpecRendererMap<PaiToolSpecs> to validate entries while preserving the catalog's actual keys. An agent tool is omitted from the result when it has no stable definition id (for example, a provider-executed tool) or the catalog has no own entry for its id; normal renderer fallbacks still apply. Optional or runtime-built catalogs produce optional properties in DerivedToolRegistry, reflecting that a lookup might not resolve.

Test a shared renderer without constructing an agent binding by using toolSpecProps<TSpec>().

Then mount the configured binding under the generated root provider:

<Pai.Provider client={paiClient}>
  <Assistant.ThreadProvider threadId={threadId}>
    <AssistantChat />
  </Assistant.ThreadProvider>
</Pai.Provider>

Add scripts so generated files stay current:

{
  "scripts": {
    "pai:generate": "pai generate client --source server/pai.ts --type AppPai --out-dir web/src/generated/pai --name Pai",
    "pai:check": "pai generate client --source server/pai.ts --type AppPai --out-dir web/src/generated/pai --name Pai --check"
  }
}

Generated files include lint-ignore headers and should be treated as build artifacts. Regenerate them when the exported contract type changes.

On this page