PAIPAI

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.

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:

ClientRequest pathWatch path
Direct/in-processCalls the runtime directly.Uses the runtime live service: local bus, optional external realtime, and explicit fallback polling.
HTTP/SSEMaps transport methods to HTTP routes.Keeps an SSE stream open; the server uses runtime live and sends snapshots and compact generation events.
WebSocketEncodes transport methods as socket messages.Receives the same watch updates over the socket.
Electron/worker IPCEncodes 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() and run.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.

On this page