PAIPAI

Layered Architecture

How agent definitions, runtimes, receivers, transports, and the client facade fit together.

PAI separates application behavior from delivery. The same agent and client semantics work in-process, over HTTP/SSE, or through another transport.

LayerOwnsDoes not own
Agent definitionModels, instructions, tools, commands, lifecycle hooks, and the inferred contractStorage, authentication, or delivery
RuntimeDurable execution, storage policy, queueing, tool execution, leases, and live publicationBrowser state or network framing
Runtime accessTrusted identity/scope-bound operationsAuthentication or public UI projection
ReceiverRequest validation, authentication, scope resolution, and response/stream encodingThread or run behavior
Client transportMoving typed operations and raw authorized watch events across one mediumModel, queue, tool, or reducer policy
Client facadeAgentClient, Thread, RunHandle, public state, optimistic sends, action sidecars, and repairHTTP routes, storage, or model execution

The direct path has no receiver:

CLI / test / server job
  -> @pai/client facade
  -> direct runtime transport
  -> RuntimeAccess
  -> runtime

A remote path adds a medium-specific pair:

browser
  -> @pai/client facade
  -> HTTP/SSE transport
  -> HTTP receiver
  -> RuntimeAccess
  -> runtime

WebSocket, IPC, and Electron integrations use the same split. Their request and stream envelopes should feel native to that medium; PAI does not impose one universal network envelope.

Agent Definition

The agent definition is the server-only source of behavior and type inference:

export const assistantAgent = defineAgent({
  name: "assistant",
  model,
  instructions,
  tools,
  commands,
  context,
});

The runtime binds it to infrastructure:

const runtime = createAgentRuntime({
  agent: assistantAgent,
  storage,
  realtime,
  files,
  scopeKey: (identity) => identity.workspaceId,
});

An agent definition does not authenticate callers or decide how values cross a network. It describes what the trusted runtime can do after a request boundary has resolved identity and scope.

Runtime

The runtime owns the authoritative workflow:

  • native conversation-message persistence;
  • run records, queue admission, and leases;
  • model calls and AI SDK message conversion;
  • backend and client-tool lifecycle policy;
  • suspension, resume, cancellation, and recovery;
  • lifecycle hooks; and
  • local live publication plus optional cross-process realtime.

Its ordinary server surface is identity-bound:

const access = runtime.access({ identity });
const directClient = runtime.client({ identity });

runtime.access() returns the trusted operation boundary used by receivers and direct transports. runtime.client() places the shared client facade over that boundary; it does not implement a second client.

Runtime Access

RuntimeAccess<TContract> is trusted and already scope-bound. Its operation names mirror AgentClientTransport<TContract> so direct delivery stays thin:

transport.send(input)
  -> access.send(input)

transport.getThreadState(input)
  -> access.getThreadState(input)

transport.watchThread(input)
  -> access.watchThread(input)

The state and watch results at this seam are authorized internal values:

type RuntimeAccess<TContract extends AgentContract> = {
  getThreadState(
    input: GetThreadStateInput,
  ): Promise<PaiThreadTransportState<TContract>>;

  watchThread(
    input: ThreadWatchInput,
  ): AsyncIterable<PaiThreadTransportEvent<TContract>>;

  // send, regenerate, stop, action, queue, command, file, and list methods...
};

PaiThreadTransportState contains native wire messages and private pending records after authorization. It is not application ThreadState, and it must not be exposed directly from an application API.

Receiver

A receiver is the untrusted-to-trusted boundary for a remote medium:

request
  -> parse and validate the medium-specific request
  -> authenticate
  -> resolve trusted identity
  -> runtime.access({ identity })
  -> call the matching operation
  -> encode its response or watch stream

An HTTP receiver owns routes, status codes, request bodies, error encoding, and SSE. A WebSocket receiver owns socket messages and subscriptions. An IPC receiver owns process messages. They share the runtime operation vocabulary, not one receiver interface.

Receivers must not:

  • add queue or tool semantics;
  • project raw native messages into a UI-specific shape;
  • accept a client-supplied scope key as authority; or
  • send storage envelopes, visibility, private metadata, or leases to clients.

Client Transport

AgentClientTransport<TContract> is the method-level SPI consumed by @pai/client. A transport moves inputs and results; it does not recreate the facade.

type AgentClientTransport<TContract extends AgentContract> = {
  getThreadState(
    input: GetThreadStateInput,
  ): Promise<PaiThreadTransportState<TContract> | null>;

  watchThread?(
    input: ThreadWatchTransportInput,
  ): AsyncIterable<PaiThreadTransportEvent<TContract>>;

  // manifest, list, send, regenerate, stop, action, queue, command, and file methods...
};

The two raw types are exported from @pai/protocol/internal for framework and transport implementations. Applications should use ThreadState from @pai/client or @pai/protocol instead.

The transport watch vocabulary is deliberately small:

  • an authoritative raw snapshot for initial load and repair;
  • generation.started and terminal generation controls;
  • generation.message.chunk, carrying an exact AI SDK UIMessageChunk;
  • native message.appended for durable messages outside active chunk replay;
  • normalized run, queue, and thread-head updates; and
  • state.changed when a fresh snapshot is required.

The transport preserves version, runId, seq, and messageId. It does not translate native text, reasoning, tool, source, file, custom, or data chunks into another event grammar.

Client Facade

Applications use the facade, regardless of transport:

const client = createPaiHttpClient({ url: "/api/pai" }).agent("main");
const thread = client.thread(threadId);
const run = await thread.send("Draft a report");

for await (const state of run.watch()) {
  render(state.messages, state.runs);
}

The facade owns behavior that must not drift between transports:

  • one public ThreadState.messages: PaiMessage[] transcript;
  • optimistic send reconciliation by the preallocated message id;
  • one long-lived AI SDK reducer session per active model message;
  • reserved PAI data-part projection;
  • generation sequence and thread-incarnation fencing;
  • snapshot repair and active-generation replay;
  • pending-action command sidecars through thread.getPendingAction(message, part); and
  • run.watch(), waitUntilBlocked(), and waitUntilIdle().

The facade never feeds enriched public PaiMessage values back into the SDK reducer. Its private raw state is replaced or reduced first, then projected in one direction for applications.

Native Watch Boundary

One generation stream is authoritative for all model messages owned by its run. It starts with generation.started at sequence zero and an exact pre-generation baseMessages baseline. Subsequent retained frames have contiguous sequence numbers beginning at one:

snapshot
generation.started seq=0 runId=run_1 baseMessages=[...]
generation.message.chunk seq=1 messageId=msg_1 chunk.type=start
generation.message.chunk seq=2 messageId=msg_1 chunk.type=start-step
generation.message.chunk seq=3 messageId=msg_1 chunk.type=text-start
generation.message.chunk seq=4 messageId=msg_1 chunk.type=text-delta
generation.message.chunk seq=5 messageId=msg_1 chunk.type=text-end
generation.message.chunk seq=6 messageId=msg_1 chunk.type=finish-step
generation.completed runId=run_1

The PAI-authored start chunk contains the trusted native message identity and metadata. That lets the client create a new model-message shell when the chunk arrives before any durable whole-message notification. Active-generation checkpoints remain silent; otherwise a checkpoint plus replay would duplicate the same parts.

A sequence gap, invalid ownership fact, unsupported event, or incarnation change triggers snapshot repair. A transport must preserve those signals rather than attempting its own merge.

Admin

runtime.admin() is server-only. It exposes trusted message and lock operations through projected lifecycle messages, not storage envelopes. If a remote admin API is needed, design it as a separate trusted protocol; do not expand the ordinary client transport with administrative authority.

Why This Split

The boundary keeps one owner for each decision:

  • the runtime owns durable workflow semantics;
  • the receiver owns authentication and medium validation;
  • the transport owns delivery;
  • the client owns private reduction and public projection; and
  • application code owns presentation.

Changing HTTP to WebSocket should change only the receiver/transport pair. It must not change Thread, RunHandle, ThreadState, message shapes, or tool renderer semantics.

On this page