Transports
Move the same client operations across HTTP, SSE, IPC, or direct calls.
Transports are adapter boundaries. They preserve the same app-facing API:
const thread = assistant.thread(threadId);
const run = await thread.send("Draft a report");Most applications use HTTP/SSE through createPaiHttpClient({ url }) from @pai/client-http.
- Layered Architecture explains the roles side by side.
- HTTP And SSE covers the default route mapping.
- Custom Transports shows how to build IPC, WebSocket, Electron, or test transports.
- Mock Transport defines the target in-memory transport API for deterministic client tests and demos.
- Watch Stream Protocol defines snapshot, thread-event, and active-generation event delivery.
The method-shaped transport contract and its input/result types live in
@pai/client. Shared
records and DTOs live in @pai/protocol. Each paired
transport and receiver owns its wire encoding.
Client Semantics, Transport Delivery
The client API owns the behavior that application code sees:
const run = await thread.send("Draft a report");
const finalState = await run.waitUntilIdle();Those methods mean the same thing for HTTP, SSE, WebSocket, Electron IPC, and
direct clients. A transport only decides how AgentClientTransport method
calls and state updates cross a boundary.
client semantics
thread.send()
thread.watch()
run.watch()
run.waitUntilBlocked()
run.waitUntilIdle()
transport primitives
getThreadState()
send()
respondToAction()
watchThread?()The client facade consumes a method-shaped AgentClientTransport. A remote
transport may encode those methods using routes, messages, or its own envelope,
but the facade does not care whether the boundary is direct calls, HTTP/SSE,
WebSocket, IPC, or tests.
watchThread() is optional. If a transport provides it, the client can observe live ThreadState changes. If it does not, the client falls back to snapshot refresh/polling for thread.watch(), run.waitUntilIdle(), and similar wait helpers.
When a transport does provide watchThread(), that watch implementation owns its own fallback behavior. For example, an HTTP/SSE server may use the runtime local bus, external realtime, active generation replay buffers, and explicit fallback polling before sending a snapshot, ordered generation.* event, typed queue event, or state.changed event to the browser.
That means transports do not reimplement queueing, pending actions, run
lifecycle, or tool behavior. They deliver transport method calls and optional
watch updates; @pai/client turns those into the stable Thread and
RunHandle APIs.
Same Client Shape, Different Implementations
Every client has the same application shape as the direct client:
const thread = assistant.thread(threadId);
const run = await thread.send("Write a status update");
for await (const state of run.watch()) {
render(state);
}The implementation can differ underneath:
| Client | Request path | Watch path |
|---|---|---|
| Direct/in-process | Calls the runtime directly. | Uses the runtime live service: local bus, optional external realtime, and explicit fallback polling. |
| HTTP/SSE | Maps transport methods to HTTP routes. | Keeps an SSE stream open; the server uses runtime live and sends snapshots and compact generation events. |
| WebSocket | Encodes transport methods as socket messages. | Receives the same watch updates over the socket. |
| Electron/worker IPC | Encodes transport methods as IPC calls. | Uses IPC messages as the watch update stream. |
The public API remains the same because the high-level client owns:
- local thread state caching;
- pending action method decoration;
- run handle lifecycle helpers;
thread.watch()andrun.watch()semantics;- snapshot repair when a watch stream misses data.
Watch Updates
thread.watch() and run.watch() always yield ThreadState to consumers. The transport watch stream is a lower-level snapshot plus ephemeral live-event stream. The shared client facade reduces ordered generation events, refreshes snapshots after invalidations, and decorates the serializable DTOs into method-bearing ThreadState.
getThreadState() snapshot
+ active generation events
+ typed queue events and state.changed invalidations
+ facade decoration
= ThreadState yielded by thread.watch()If the client reconnects, receives an unsupported event, sees a generation
sequence mismatch, or cannot reduce an event against its current local state,
it refreshes the authoritative snapshot and attaches the active generation when
one exists. There is no public event replay cursor. Compact generation and
ToolData events carry ThreadVersion, so delayed events from a deleted thread
incarnation cannot update a recreated thread id.