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.
| Layer | Owns | Does not own |
|---|---|---|
| Agent definition | Models, instructions, tools, commands, lifecycle hooks, and the inferred contract | Storage, authentication, or delivery |
| Runtime | Durable execution, storage policy, queueing, tool execution, leases, and live publication | Browser state or network framing |
| Runtime access | Trusted identity/scope-bound operations | Authentication or public UI projection |
| Receiver | Request validation, authentication, scope resolution, and response/stream encoding | Thread or run behavior |
| Client transport | Moving typed operations and raw authorized watch events across one medium | Model, queue, tool, or reducer policy |
| Client facade | AgentClient, Thread, RunHandle, public state, optimistic sends, action sidecars, and repair | HTTP routes, storage, or model execution |
The direct path has no receiver:
CLI / test / server job
-> @pai/client facade
-> direct runtime transport
-> RuntimeAccess
-> runtimeA remote path adds a medium-specific pair:
browser
-> @pai/client facade
-> HTTP/SSE transport
-> HTTP receiver
-> RuntimeAccess
-> runtimeWebSocket, 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 streamAn 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
snapshotfor initial load and repair; generation.startedand terminal generation controls;generation.message.chunk, carrying an exact AI SDKUIMessageChunk;- native
message.appendedfor durable messages outside active chunk replay; - normalized run, queue, and thread-head updates; and
state.changedwhen 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(), andwaitUntilIdle().
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_1The 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.