Skip to main content
The gateway now ships in the praisonai-bot package. praisonai serve gateway still works exactly as documented here; for a standalone install see praisonai-bot Migration.
GatewayClient is a reconnecting WebSocket client that handles version negotiation, exponential backoff with jitter, and event-sequence gap detection — so your integration stays connected without you writing the socket loop.
The user’s integration connects with GatewayClient; the client negotiates the handshake, streams events, and reconnects after disconnects.

Quick Start

1

Simplest Usage

2

With Backoff Tuning

3

With Gap and State Callbacks


How It Works

Calling client.connect() on an instance whose previous loop had a server-supplied retry_after floor clears that floor before the new loop starts. Reusing a GatewayClient after a terminal failure will not inherit a stale backoff from the prior connection cycle — the clear happens on every connect() call, not only the first.

Configuration Options

GatewayClient constructor: BackoffConfig fields: Connection states (from ConnectionState): Callbacks (set as attributes on the client instance): send() method: Returns: Optional[str] — the request_id used (auto-generated when needed), or None when no idempotency key was involved.

Protocol Version Negotiation

The client and server negotiate a protocol version during the join handshake.
  • Constants in protocols.py: PROTOCOL_VERSION = 1, MIN_PROTOCOL_VERSION = 1, MAX_PROTOCOL_VERSION = 1.
  • Client sends min_version and max_version in the join message.
  • Server replies in joined with protocol_version, server_min_version, server_max_version.
version_unsupported is a permanent error. When the server returns {"type": "error", "code": "version_unsupported"}, GatewayClient.connect() raises ValueError and does not retry. Wrap your connect() call in a try/except ValueError and do not loop on this error.
Invalid min_version/max_version fields (non-integer, or min > max) produce code: "invalid_protocol_hello" and raise ConnectionError.

Terminal vs Transient Connect Errors

Not every connect failure should be retried. The client uses a single classifier — is_recoverable(code) from praisonaiagents.gateway.protocols — to decide whether to back off and try again, or stop the reconnect loop and surface the failure through on_reconnect_paused.
agent_not_found is terminal because the gateway emits it with a do_not_retry recovery step — the agent id in your hello frame does not exist on the server, so retrying will not help until a human fixes the id or registers the agent. The client used to loop forever on this code; it now pauses immediately.
Surface a terminal reason and drive re-pairing without silently looping:
Once on_reconnect_paused fires, client.connect() has already set _running = False. To resume connecting after the operator fixes the underlying condition (re-auth, re-pair, correct agent_id), call await client.connect() again — it will start a fresh loop with a cleared backoff floor.

Turn-level failures are different: connect errors use is_recoverable(code) and close the socket, while a failed turn arrives as outcome.error on the final response frame, the socket stays open, and your code decides whether to resend. See Gateway Turn Error Codes.

Gap Detection

Every event carries a monotonic sequence field; the client tracks _expected_sequence and fires on_gap when there’s a mismatch.
on_gap is a synchronous Callable[[int, int], None]. You cannot await inside it. To call client.resync() from within the callback, schedule it as a task:
client.resync() resets the cursor to 0 and reconnects, triggering a full state reload from the server.

Projected State (De-duplicated View)

client.project() folds the raw event stream into a correct, resumable session view — no manual de-dup, gap-healing or bounded retention.
project(snapshot=None, *, max_tracked_runs=200) seeds from an optional snapshot, then yields a SessionProjectionState after every event. See Session Projection for the full behaviour, config options, and de-dup semantics.

Resume Snapshot

When reconnecting with a stored session_id and since=cursor, the server replies with a single joined payload that restores full client state in one round trip. The joined payload includes: This means a reconnecting client learns current presence and health without extra requests — one round trip restores all state.

Common Patterns


Network Blip: What Users See

When a network blip occurs, the client handles reconnection silently — users see no errors and events resume from the cursor automatically. The bot keeps responding because:
  1. Events are buffered until the cursor is confirmed
  2. The since=cursor on reconnect replays any events delivered while offline
  3. Users see continuous responses with no error messages
The diagram above covers the inbound side — events the client receives during a blip. The next section covers the outbound side — a message the user sends during a blip.

Exactly-Once Send (Queue and Flush)

One flag makes an outbound message survive a reconnect and run exactly once.
Without a request_id, an in-flight send() is either lost on a socket drop (at-most-once) or double-runs the turn if you resend (at-least-once). Attaching a stable request_id lets the server dedup a resend, so a queued-and-flushed message runs exactly once.

What the user experiences

A user chatting through the bot sends a message during a Wi-Fi blip; with queue=True the message is held, flushed on reconnect, and the agent runs the turn once.

How it works

Choose your mode

Return value and correlation

send() returns Optional[str] — the request_id actually used (auto-generated when needed), or None when no idempotency key was involved. Persist it to correlate the accepted ack and match the eventual final response.
Semantic guarantees (pinned by the request-idempotency test suite):
  1. Same request_id → the server dedups (the turn runs once).
  2. Distinct request_ids → both turns run.
  3. Missing request_id → legacy behaviour (no dedup — keeps existing clients working).
  4. If the turn never actually ran (agent unavailable, /stop, /cancel, or the enqueue itself raised), the reservation is released so a later retry with the same id gets a real chance.

Common patterns

This is not a breaking change. queue defaults to False and a missing request_id keeps the legacy raise-on-disconnect, no-dedup behaviour — existing callers keep working unchanged.

Best Practices

The defaults (initial=1.0, max=30.0, multiplier=2.0) suit residential and cellular networks. For LAN-only deployments with fast recovery, tighten initial to 0.1 and max to 5.0.
The reconnect loop stops on terminal codes (auth_*, pairing_required, protocol_unsupported, agent_not_found, origin_not_allowed, configuration_error). Without a callback you have no way to know it stopped — logs are the only signal. Always set on_reconnect_paused before connect() and route the code to re-auth, re-pair, or an ops alert. See Terminal vs Transient Connect Errors for the full classification.
connect() raises ValueError on version_unsupported and stops retrying. Wrap it and alert your ops team — this means the server and client are incompatible and need a coordinated upgrade. This is the protocol-negotiation counterpart to the other terminal codes covered in Terminal vs Transient Connect Errors.
If your app already has its own snapshot mechanism, prefer it over resync(). A targeted snapshot is faster than a full cursor reset.
Long-running batch jobs should not retry forever on a dead gateway. Set max_reconnect_attempts so the job fails fast and can be requeued.

Gateway

WebSocket control plane for multi-agent coordination

Gateway Overview

Architecture and deployment patterns

Session Persistence

How sessions survive across processes

Stream Events

Live progress events forwarded over WebSocket

Turn Error Codes

Typed outcome.error for failed turns

Session Projection

De-duplicated, gap-aware view via client.project()