PAIPAI

Define An Agent

Declare behavior, schemas, tools, and contract types.

defineAgent() creates the server-side definition for an agent.

model accepts an AI SDK LanguageModel, a ModelSelection, or a resolver that returns either form. Use normal AI SDK provider models; PAI handles message and tool conversion around the model call internally.

import { openai } from "@ai-sdk/openai";
import { defineAgent, type InferAgentContract } from "@pai/core";
import { z } from "zod";

export const assistantAgent = defineAgent({
  name: "report",

  identity: z.object({
    workspaceId: z.string(),
  }),

  clientData: z
    .object({
      source: z.enum(["web", "cli"]).optional(),
      activeDocumentId: z.string().optional(),
    })
    .optional(),

  model: ({ runtimeContext }) => runtimeContext.models.primary,

  instructions: ({ runtimeContext }) =>
    `Draft concise reports for ${runtimeContext.workspace.name}.`,

  runtimeContext: async ({ identity, clientData, signal }) => ({
    workspace: await loadWorkspace(identity.workspaceId, { signal }),
    models: {
      primary: openai("gpt-4.1-mini"),
    },
    clientData,
  }),

  tools: {
    createTask,
    requestApproval,
  },

  commands: {
    // Server-only unless the command itself declares expose: "client".
    rebuildIndex,
  },
});

export type AssistantAgentContract = InferAgentContract<typeof assistantAgent>;

What Belongs Here

Agent definitions declare:

  • name and version;
  • scope schema;
  • clientData schema;
  • model selection;
  • instructions;
  • runtime context builder;
  • tools and their context-aware availability;
  • dynamic backend tool providers;
  • commands;
  • lifecycle hooks for advanced model/tool behavior;
  • a default per-episode model-step safety ceiling;
  • thread title generation policy;
  • inferred public contract source.

They should not bind storage, realtime, HTTP framework routes, or product access policy.

For RAG, guardrails, canonical redaction, model fallback, per-step shaping of tools that are already enabled, and tool input/output policy, use Lifecycle Hooks. Registration-level enabled is the authoritative availability boundary; lifecycle patches cannot re-enable a disabled tool.

For app-specific server actions clients can invoke on a thread, see Commands.

Model Selection

Use a ModelSelection when generation settings or provider-specific options belong to the chosen model:

import type { OpenAILanguageModelResponsesOptions } from "@ai-sdk/openai";

export const assistantAgent = defineAgent({
  // ...
  model: ({ runtimeContext }) =>
    runtimeContext.useDeepReasoning
      ? {
          model: runtimeContext.models.deep,
          modelSettings: {
            temperature: 0.2,
            maxOutputTokens: 8_000,
          },
          providerOptions: {
            openai: {
              reasoningEffort: "medium",
            } satisfies OpenAILanguageModelResponsesOptions,
          },
        }
      : runtimeContext.models.fast,
});

modelSettings contains portable AI SDK generation controls. Provider-specific configuration remains under the JSON-compatible providerOptions envelope. Use the provider package's option type with satisfies for exact provider-specific checking, as above. PAI keeps cancellation, retries, timeouts, and transport headers under runtime control.

The main model resolver is evaluated once after prepareRun continues into the primary model loop, and its selection is reused across every model step in that admitted execution. A prepareRun patch that supplies its own model skips the root resolver. Use prepareModelStep for intentional per-step routing. A resumed execution resolves the selection again only if it continues into the primary model loop.

One output-policy edge is intentionally different: when prepareRun returns a synthetic assistant response and transformStepStream is configured, PAI resolves the root model to construct the complete request supplied to that transform. No model provider call is made.

Thread-title and reasoning-summary generators may reuse the selected language model, but they do not inherit the main run's model settings or provider options.

Instructions

PAI accepts the AI SDK's exact Instructions value for the primary model, thread-title generator, and reasoning-summary generator. Existing strings keep the same behavior. Structured instructions can preserve separate system messages and attach provider options to a specific message:

import type { AnthropicLanguageModelOptions } from "@ai-sdk/anthropic";

export const assistantAgent = defineAgent({
  // ...
  instructions: [
    {
      role: "system",
      content: "Use the workspace reference material.",
      providerOptions: {
        anthropic: {
          cacheControl: { type: "ephemeral" },
        } satisfies AnthropicLanguageModelOptions,
      },
    },
    { role: "system", content: "Answer concisely." },
  ],
});

The array remains an ordered sequence of system messages; PAI does not join it into one string. Multiple system messages and individual provider options work only where the selected provider supports them.

The root instructions resolver is evaluated while assembling each primary model-step request unless prepareRun supplies an episode-level replacement. prepareRun, prepareModelStep, and aroundModelCall replace the complete instruction value at their existing scopes; arrays are not merged. A text-bearing synthetic prepareRun response also resolves the root value when transformStepStream is configured, but does not call a model provider.

Provider Tools

providerTools accepts AI SDK provider-defined tools that execute inside the model provider rather than the PAI runtime. Register them under the exact name required by the provider:

import { createVertex } from "@ai-sdk/google-vertex";
import { defineAgent, type InferAgentContract } from "@pai/core";

const vertex = createVertex({
  project: process.env.GOOGLE_CLOUD_PROJECT,
  location: process.env.GOOGLE_CLOUD_LOCATION,
});

const assistantAgent = defineAgent({
  name: "analyst",
  model: vertex("gemini-2.5-flash"),
  instructions: "Use current sources when the question needs them.",
  providerTools: {
    google_search: vertex.tools.googleSearch({}),
  },
});

type Contract = InferAgentContract<typeof assistantAgent>;
type SearchTool = Contract["tools"]["google_search"];

Provider-declared tool schemas are included in the inferred agent contract, so React tool renderers receive the input and output types the provider exposes. When a provider emits native tool call/result parts, those parts have providerExecuted: true and cannot be registered as client-executed tools.

PAI preserves AI SDK provider-tool parts, but provider-specific request and conversation-history encoding is implemented by the installed provider package. Support can vary by provider, tool, and version, so verify multi-turn behaviour before adopting a provider-native tool. A tool appearing in the static PAI contract does not add provider capabilities that the provider package does not support.

providerTools is a static map because those schemas are the authority used to validate retained native tool parts. Lifecycle model patches may advertise a subset for a route, but the subset must reuse the exact definitions declared on the agent. It cannot introduce a name or replace a schema for one call. Removing or incompatibly changing a provider tool therefore requires resetting retained development threads during the current direct-reset phase.

Not every provider tool produces a tool call. For example, Google Search grounding commonly emits AI SDK source parts instead; render those as source parts rather than inventing a synthetic tool result.

Provider-executed only

The AI SDK splits provider tools by who runs them. providerTools takes the provider-executed arm (isProviderExecuted: true) — web search, code execution, anything the provider runs inside its own turn. Provider factories set the flag themselves, so a declaration written as above is unaffected.

The other arm — provider-declared but application-executed, such as Anthropic's bash, computer, and textEditor — is rejected at agent construction. PAI strips execute before the model call and has nowhere to route the call back to, so accepting it would advertise a tool to the model that nothing runs. Note that redeclaring such a tool with defineTool is not a workaround: that sends a generic JSON-schema function tool rather than the provider's native tool type and beta header, which is a different request. Support is tracked in issue #228.

Identity

identity is the trusted, server-resolved caller. It is validated against the schema declared here, and it is the only trusted input an agent receives.

identity: z.object({
  userId: z.string(),
  workspaceId: z.string(),
})

The application derives the durable partition key and the actor key from it in createPai({ scopeKey, userKey }) — the agent declares the shape, the application decides what to derive.

See Scope And Access.

clientData

clientData is untrusted request metadata from the client. It can influence UX or model context, but it must not be used as access-control data.

It can arrive from either of two places, because a server usually resolves identity from the request's auth while the browser carries client data in the body of each send:

runtime.client({ identity, clientData })   // once, on the handle
thread.send("hello", { clientData })          // per call, overriding the handle

The schema runs on whichever value arrives, and the call's value wins. Absence is judged once, after the two are merged — so a plain object schema makes clientData mandatory, and a send that supplies it nowhere throws before the run starts. Declare .optional() when a caller may leave it out, or .default() to fill it in:

clientData: z.object({ locale: z.string() }).optional()
clientData: z.object({ locale: z.string() }).default({ locale: "en" })

Triggers and subagent delegations have no per-call source behind them, so a required schema must be satisfied by the handle that issues the trigger, or by the client data the parent delegates to the child.

A failure here throws IdentityValidationError, which names the field it rejected and carries the schema's own issues. The HTTP receiver answers it with a 400.

To resolve an identity without driving the agent — asking whether a tool would be enabled, or what runtimeContext would build — use runtime.resolveIdentity(). It runs every declared schema and returns what a real turn would receive, rather than the raw request values.

What is persisted

identity and clientData are normally request-lifetime only. There is one exception: when a send or trigger arrives while the thread is busy and the caller queues it, the resolved values are written to durable storage alongside the queued messages, because the originating request is gone by the time that turn is admitted.

The row is deleted the moment the turn is admitted or cancelled, so this is a waiting room rather than an archive. But the wait is bounded by whatever the thread is doing — a tool suspended on human approval can hold a queued turn for hours. Choose what these schemas carry with that in mind: PAI persists exactly what your resolvers return, and does not inspect or filter it.

Admission replays the captured values rather than re-resolving them, so a queued turn executes with the permissions its sender had at send time. Tools that must act on current permissions should re-check inside enabled or execute, both of which run at execution time with the identity in hand.

Runtime context

runtimeContext builds the runtime-only value passed to model selectors, instructions, lifecycle hooks, and tool registration callbacks.

Use it to load trusted server data from identity.

Tool registrations may use enabled: ({ runtimeContext }) => boolean to expose a tool only when the current runtime context allows it. This controls model-facing availability; each tool must still authorize its concrete operation inside execute. Tool-local state remains ctx.context; derive it with mapContext({ runtimeContext }) when needed. See Backend Tools.

Thread Titles

When storage is configured, PAI can generate a persisted title after the first user message:

export const assistantAgent = defineAgent({
  // ...
  threadTitles: {
    instructions: "Generate a short workspace report title.",
  },
});

Set threadTitles: false if your app writes titles itself through thread metadata.

Contract Type

The agent definition infers a contract type for clients:

// web/src/pai-client.ts
import type { AssistantAgentContract } from "../../server/assistant-agent";

Clients should import the contract with import type so no server runtime code enters the client bundle. If your build boundary cannot import from the server package directly, move the inferred contract type into a client-safe contract package. See Contract Types.

On this page