Custom Transports
Build IPC, WebSocket, Electron, or test transports.
Custom transports plug into the shared client facade by implementing AgentClientTransport.
type AgentClientTransport<TContract extends AgentContract = AgentContract> = {
getManifest(): Promise<AgentManifest<TContract>>;
listThreads(options?: ThreadListOptions): Promise<ThreadListResult>;
renameThread(input: ThreadRenameInput): Promise<ThreadRenameResult>;
deleteThread(input: { threadId: ThreadId }): Promise<void>;
getThreadState(input: GetThreadStateInput): Promise<PaiThreadTransportState<TContract> | null>;
send(input: ThreadSendInput<TContract>): Promise<ThreadSendResult<TContract>>;
regenerate(input: ThreadRegenerateInput<TContract>): Promise<ThreadRegenerateResult<TContract>>;
stopThread(input: StopThreadInput): Promise<ThreadMutationResult<TContract>>;
stopRun(input: StopRunInput): Promise<ThreadMutationResult<TContract>>;
respondToAction(input: RespondToActionInput<TContract>): Promise<RespondToActionResult<TContract>>;
writeToolData(input: WriteToolDataInput<TContract>): Promise<WriteToolDataResult<TContract>>;
recallQueuedItem(input: RecallQueuedItemInput): Promise<RecallQueuedItemResult<TContract>>;
executeCommand<TName extends CommandName<TContract>>(
input: ExecuteCommandInput<TContract, TName>,
): Promise<CommandOutput<TContract, TName>>;
getRun(input: GetRunInput): Promise<RunStateDTO | null>;
uploadFile(input: FileUploadInput): Promise<UploadedFile>;
createFileUrl(input: ClientCreateFileUrlInput): Promise<FileUrlResult | null>;
readFile(input: ClientReadFileInput): Promise<StoredFileBody>;
/** Optional live watch channel. If omitted, the client falls back to snapshot polling. */
watchThread?(input: ThreadWatchTransportInput): AsyncIterable<PaiThreadTransportEvent<TContract>>;
watchThreadHeads?(input: ThreadHeadWatchInput): AsyncIterable<ThreadHeadWatchEvent>;
/** Release transport resources. */
close?(): Promise<void>;
};AgentClientTransport is intentionally lower-level than the app API. It does not implement thread.send(), thread.watch(), or run.waitUntilIdle() itself. Those are client semantics built by @pai/client on top of transport methods.
If watchThread() is present, the client uses it for live state. If it is absent or fails and requires repair, the client can refresh the authoritative snapshot through getThreadState().
PaiThreadTransportState and PaiThreadTransportEvent are framework transport
contracts from @pai/protocol/internal; they are not application state. A
transport must carry them losslessly and let @pai/client perform the sole
public PaiMessage projection.
When ThreadWatchTransportInput.includeInitial is not false, watchThread() must emit
an authoritative raw snapshot before live events. For an unrealized thread,
that snapshot contains the empty authorized transport state. When
includeInitial is false, the stream may begin directly with live events.
Call input.onConnectionEstablished?.() once the transport has accepted the
subscription and can receive events. Do not wait for the first event: a healthy
thread or thread-head stream may remain quiet indefinitely. The client falls
back to first-event reporting only for transports that omit this signal.
watchThread() emits an authoritative snapshot, ordered native
generation.message.chunk updates for the active generation, typed control
events such as queue add/remove, and state.changed invalidations for other
durable state changes. The high-level client owns local state caching, keeps one
AI SDK reducer session per active message, repairs gaps from snapshots, and
projects the result into the same ThreadState that direct clients expose.
Simple transports can emit snapshots for initial load and repair. Streaming transports should use compact frames for high-frequency changes:
snapshot version=instance_1:3
generation.started version=instance_1:3 runId=run_1 seq=0 baseMessages=[...]
generation.message.chunk version=instance_1:3 runId=run_1 seq=1 messageId=msg_a1 chunk.type=start
generation.message.chunk version=instance_1:3 runId=run_1 seq=2 messageId=msg_a1 chunk.type=start-step
generation.message.chunk version=instance_1:3 runId=run_1 seq=3 messageId=msg_a1 chunk.type=text-start
generation.message.chunk version=instance_1:3 runId=run_1 seq=4 messageId=msg_a1 chunk.type=text-delta
generation.message.chunk version=instance_1:3 runId=run_1 seq=5 messageId=msg_a1 chunk.type=tool-input-available
queue-item-added version=instance_1:4 queued_msg_1
generation.message.chunk version=instance_1:3 runId=run_1 seq=6 messageId=msg_a1 chunk.type=tool-output-available
generation.message.chunk version=instance_1:3 runId=run_1 seq=7 messageId=msg_a1 chunk.type=finish-step
generation.message.chunk version=instance_1:3 runId=run_1 seq=8 messageId=msg_a2 chunk.type=start
generation.message.chunk version=instance_1:3 runId=run_1 seq=9 messageId=msg_a2 chunk.type=text-start
generation.message.chunk version=instance_1:3 runId=run_1 seq=10 messageId=msg_a2 chunk.type=text-delta
generation.completed version=instance_1:3 runId=run_1If the transport cannot deliver generation events in sequence, emit
state.changed or close the stream. The client will repair by reading the
latest state through getThreadState(), then replaying the active generation
stream when available. generation.started makes that replay authoritative for
the run's model messages, so a transport must not translate native chunks into
its own text/tool grammar or mix checkpointed parts into the replay baseline.
The paired transport and receiver own their wire contract. HTTP uses routes,
request bodies, status codes, and SSE; a WebSocket or IPC implementation may
define its own method discriminator or envelope. None of those encodings is a
universal @pai/protocol request shape.
Preserve canonical ids such as threadId, messageId, and fileId when
encoding the matching AgentClientTransport method, even when an HTTP route
also contains an id as a path parameter.
Adapter authors should preserve:
- request validation;
- authentication and scope resolution on trusted backend boundaries;
- scope isolation;
- caller-chosen ids on operations that support retry deduplication;
- typed errors;
- watch recovery through snapshots.
Do not implement separate queueing, pending action, or tool semantics in a transport.
Test a custom transport through the client facade rather than by calling
transport methods directly: construct createAgentClient(createMyTransport(...))
and assert the app-facing behaviour. PAI ships no shared conformance suite for
this today.