Gateway Handshake Protocol

Versioned WebSocket handshake with capability negotiation and structured errors

python
from praisonaiagents import Agent

agent = Agent(name="handshake-agent", instructions="Perform gateway connection handshake.")
agent.start("Establish a secure handshake with the remote gateway.")

The gateway handshake lets clients and servers agree on a protocol version, discover supported features, and recover from disconnects — all in one round trip.

python
import asyncio
from praisonai.gateway import GatewayClient

async def main():
    client = GatewayClient(url="ws://localhost:8765", agent_id="assistant")
    await client.connect()  # sends hello; receives hello_ok with negotiated protocol

asyncio.run(main())

The user connects via GatewayClient; hello and hello_ok negotiate protocol version, features, and session cursor in one round trip.

Diagram

hello

hello_ok

hello_error

Client

Gateway

Quick Start

  1. Minimal hello
    json
    {
      "type": "hello",
      "agent_id": "assistant",
      "protocol_min": 1,
      "protocol_max": 1
    }
  2. Opt into capabilities
    json
    {
      "type": "hello",
      "agent_id": "assistant",
      "protocol_min": 1,
      "protocol_max": 1,
      "capabilities": ["streaming", "presence", "ack"]
    }
  3. Resume a session
    json
    {
      "type": "hello",
      "agent_id": "assistant",
      "protocol_min": 1,
      "protocol_max": 1,
      "session_id": "abc-123",
      "since": 42
    }

    After hello_ok, replayed events arrive as {"type": "replay", "event": …} before normal traffic resumes.

  4. Prove device identity
    json
    {
      "type": "hello",
      "agent_id": "assistant",
      "protocol_min": 1,
      "protocol_max": 1,
      "device": {
        "device_id": "c88e80202a4d650841e48649b4c3f553e48131b415db680476fd0d65632ff2b0",
        "public_key": "dGVzdC1wdWJsaWMta2V5",
        "signature": "<base64 signature over DeviceAuth.signing_payload(agent_id)>",
        "signed_at": 1700000000,
        "nonce": "<nonce issued by the gateway>"
      }
    }

    The device block is optional. When present, the gateway validates its shape and checks that device_id matches the public-key fingerprint before any wrapper-level signature check — see Gateway Device Identity.


How It Works

Diagram
GatewayClientGatewayClientalt[success][version too old][version too new][unknown agent][malformed device block][device_id ≠ public-key fingerprint]alt[rate limited][origin denied][auth missing][accepted]WS upgradehello_error (rate_limited, wait_then_retry, retry_after_seconds)close 4008hello_error (origin_not_allowed, do_not_retry)close 4003hello_error (auth_required, reauthenticate)close 4003hello (agent_id, protocol_min/max)hello_ok (protocol, features, policy, session_id)hello_error (protocol_unsupported, upgrade_client)hello_error (protocol_unsupported, downgrade_client)hello_error (agent_not_found, do_not_retry)hello_error (configuration_error, do_not_retry)hello_error (auth_unauthorized, reauthenticate)

Negotiated protocol version: min(client_max, GATEWAY_PROTOCOL_VERSION) where server version is 1 and minimum accepted client version is 1.

Legacy join → joined still works for existing clients. New clients should prefer hello.

A hello carrying a device block adds two rejection paths:

  • Device shape rejected — device is not an object, public_key/device_id/signature/nonce is missing or empty, signed_at is not an integer, or public_key is not padded standard base64 → configuration_error / do_not_retry.
  • Spoofed device_id — device.device_id does not match the SHA-256 fingerprint of device.public_key → auth_unauthorized / reauthenticate.

A hello without a device block decodes exactly as before.

Scope requirement

Handshake frames (hello, join, leave) classify as read in the Method → Scope Registry. They must run before a client has negotiated any scope, so requiring anything higher would make the handshake unreachable. Any operator token (viewer, ops, admin) can complete the handshake; scope only starts to matter for the frames that follow.


HelloParams (client → server)

FieldTypeDefaultDescription
agent_idstr—Agent to connect to
protocol_minint—Minimum protocol version client supports
protocol_maxint—Maximum protocol version client supports
capabilitiesList[str][]e.g. streaming, presence, ack
session_idOptional[str]NoneExisting session to resume
sinceOptional[int]NoneEvent cursor for replay
deviceOptional[DeviceAuth]NoneOptional device-identity proof. Carries a per-device public key, the signed challenge nonce, and the signing time. Omitting it keeps the frame byte-for-byte compatible with older clients. See Gateway Device Identity.

Legacy nested protocol: {min, max} is also accepted; missing values fall back to 1.


HelloResult (server → client, hello_ok)

FieldTypeDescription
protocolintNegotiated protocol version
featuresDict[str, List[str]]Supported methods and events
policyDict[str, Any]max_payload, max_buffered_bytes, max_queued_frames, heartbeat_ms. When attachments are enabled it also carries max_attachment_bytes, max_attachments, chunk_bytes, and allowed_attachment_types (added only when attachments are enabled) — see Gateway Attachments
session_idstrSession ID (new or resumed)
resumedboolTrue if an existing session was resumed
cursorintCurrent event cursor

Example success frame:

json
{
  "type": "hello_ok",
  "protocol": 1,
  "features": {"methods": ["message", "leave"], "events": ["message", "error", "token_stream", "tool_call_stream", "stream_end", "reasoning_stream", "tool_progress_stream", "model_fallback_stream", "retry_stream", "todo_stream", "tool_result_stream", "stream_error"]},
  "policy": {"max_payload": 1048576, "max_buffered_bytes": 8388608, "max_queued_frames": 1000, "heartbeat_ms": 30000},
  "session_id": "...",
  "resumed": false,
  "cursor": 0
}

With attachments enabled, the policy gains the attachment ceilings a /ws client self-limits against:

json
{
  "type": "hello_ok",
  "protocol": 1,
  "policy": {
    "max_payload": 1048576,
    "heartbeat_ms": 30000,
    "max_attachment_bytes": 10485760,
    "max_attachments": 10,
    "chunk_bytes": 262144,
    "allowed_attachment_types": ["image/", "application/pdf"]
  },
  "session_id": "...",
  "resumed": false,
  "cursor": 0
}

When attachments are disabled, none of those keys are added and the policy is byte-for-byte the shape above — see Gateway Attachments for the full story.

Base methods: message, leave only (abort is not implemented). Base events: message, error.


HelloError (server → client, hello_error)

FieldTypeDescription
codeConnectErrorCodeStructured, machine-readable error code
messagestrHuman-readable explanation (display only)
next_stepOptional[ConnectRecoveryStep]Machine-readable recovery hint
retry_after_secondsOptional[int]Backoff hint, only meaningful with wait_then_retry
next_actionOptional[str]Deprecated free-text hint; prefer next_step

Example wire frame (rate-limited):

json
{
  "type": "hello_error",
  "code": "rate_limited",
  "message": "Too many connection attempts",
  "next_step": "wait_then_retry",
  "retry_after_seconds": 30,
  "next": "wait_then_retry"
}

ConnectErrorCode

CodeMeaningTypical next_step
auth_requiredAuthentication missing on a non-loopback bindreauthenticate
auth_unauthorizedInvalid credentials or wrong agent for sessionreauthenticate
protocol_unsupportedClient/server version mismatchupgrade_client or downgrade_client
pairing_requiredClient must complete pairing firstrepair
agent_not_foundUnknown agent_iddo_not_retry
rate_limitedToo many connection attempts — driven by an injectable RateLimitPolicyProtocolwait_then_retry (+ retry_after_seconds)
origin_not_allowedOrigin not in allowed list (CSWSH defence)do_not_retry
configuration_errorServer is misconfigured (e.g. external bind with no allowlist)do_not_retry

ConnectRecoveryStep

Machine-readable recovery hint. Clients branch on (code, next_step) instead of parsing message.

ValueMeaning
reauthenticateObtain fresh credentials, then reconnect
repairRe-run device pairing, then reconnect
upgrade_clientClient protocol too old — update the client
downgrade_clientClient protocol newer than server — use an older client
wait_then_retryBack off (retry_after_seconds), then reconnect
do_not_retryTerminal — reconnecting will not help

Capability Matrix

CapabilityEvents unlockedServer prerequisite
(none)message, error—
streamingtoken_stream, tool_call_stream, stream_end, reasoning_stream, tool_progress_stream, model_fallback_stream, retry_stream, todo_stream, tool_result_stream, stream_error—
presencepresence_join, presence_leave, presence_updateserver has _presence_tracker
ackmessage_ack, message_nack, delivery_retryserver has _delivery_tracker

Events are advertised only if the client requested the matching capability. The ten streaming events — including their payloads and the accepted vs final response frame — are detailed in Gateway Stream Events.


Server-side state after handshake

After hello_ok is sent, the gateway records the negotiated protocol version and the client’s advertised capabilities on the session itself. Both are read-only and survive resume.

python
session = gateway.get_session(session_id)
session.protocol_version    # → 1   (negotiated min(client_max, GATEWAY_PROTOCOL_VERSION))
session.capabilities        # → ["streaming", "presence", "ack"]   (defensive copy)
PropertyTypeNotes
session.protocol_versionintRead-only. Set by the hello handler from the negotiated value.
session.capabilitiesList[str]Read-only. Returns a copy — mutating the returned list does not change session state. Empty list when the client advertised no capabilities.

Both properties are populated on the hello path and the legacy join path, so server code can branch on session.capabilities without checking which handshake the client used.


Client-side wiring (praisonai-bot)

The bundled praisonai-bot[gateway] client speaks the modern handshake for you — construct it with the capabilities you handle, connect(), then read the negotiated manifest off the instance.

python
import asyncio
from praisonai_bot.gateway import GatewayClient, PayloadTooLarge

async def main():
    client = GatewayClient(
        url="ws://localhost:8765",
        agent_id="assistant",
        capabilities=["streaming", "ack"],   # what to advertise in `hello`
    )
    await client.connect()

    # Read the negotiated manifest — empty on legacy gateways.
    print("protocol   :", client.features)               # {"methods": [...], "events": [...]}
    print("policy     :", client.policy)                 # {"max_payload": ..., "heartbeat_ms": ...}
    print("heartbeat  :", client.heartbeat_ms, "ms")     # convenience getter

    # Gate optional behaviour on the negotiated event set instead of probing.
    if client.supports_event("token_stream"):
        # enable streaming UI
        pass
    if client.supports_event("reasoning_stream"):
        # render live reasoning bubble
        pass
    if client.supports_event("tool_progress_stream"):
        # render tool progress indicator
        pass

    # Self-guarded send — oversized frame fails locally, never hits the wire.
    try:
        await client.send({"type": "message", "content": "hello"})
    except PayloadTooLarge as e:
        print("dropped:", e)

asyncio.run(main())

The client always sends hello first — capabilities= controls what it advertises, not whether the modern handshake is used.

Client construction

ArgumentTypeDefaultDescription
capabilitiesOptional[List[str]]None → ["streaming", "ack"]Capability tokens advertised in the outbound hello frame. None uses the default; [] explicitly opts out of every capability. The list is defensively copied into self.capabilities.

Negotiated accessors

connect() populates these from the server’s hello_ok. Against a legacy gateway they stay at permissive defaults so existing code keeps working.

AttributeTypeLegacy defaultDescription
client.capabilitiesList[str]echoed from constructorThe capability tokens actually sent in hello. Client intent, never mutated by the handshake.
client.featuresDict[str, List[str]]{}Negotiated feature manifest: {"methods": [...], "events": [...]}. Empty dict on a legacy gateway.
client.policyDict[str, int]{}Negotiated transport policy (max_payload, max_buffered_bytes, max_queued_frames, heartbeat_ms). Empty dict on legacy.
client.heartbeat_msOptional[int]NoneConvenience getter → policy["heartbeat_ms"] coerced to a non-negative int. Strings and floats coerce; booleans and unparseable values yield None.

supports_event(event)

Gate optional behaviour on the negotiated event set instead of try: … except: probing.

python
from praisonaiagents.gateway import EventType

client.supports_event("token_stream")            # str lookup
client.supports_event("reasoning_stream")        # new streaming event
client.supports_event("tool_progress_stream")    # new streaming event
client.supports_event(EventType.MESSAGE)         # EventType — uses .value
  • Accepts a str (e.g. "token_stream") or an EventType member; the enum’s .value is used for lookup.
  • Returns True for legacy gateways (empty features["events"]) so code that assumed everything worked keeps working — this is the intentional permissive default.
  • Otherwise returns True iff event in client.features["events"].

Handshake flow — client branch on the server’s first reply

The wire is documented server-side above; this is what the client does with each reply.

Diagram

hello_ok

hello_error

terminal

transient

error version_unsupported

error other code

joined bare

anything else

📤 send hello

First reply?

✅ Adopt HelloResult
features, policy, session

Recoverable?

🛑 GatewayConnectError

🔁 ConnectionError → backoff

🛑 ValueError

↩️ fall back to legacy join

↩️ adopt legacy join
_negotiated=False

🛑 ConnectionError

Server’s reply to helloClient actionNotes
hello_okAdopt HelloResult → populate features, policy, session_id, cursor, protocol_version. Set _negotiated=True.Canonical modern path.
hello_errorClassify via ConnectErrorCode: terminal → raise GatewayConnectError, transient → raise ConnectionError (reconnect loop backs off, honouring retry_after_seconds).Same classification, now reachable via hello_error.
error (bare), code="version_unsupported"Raise ValueError("Protocol version unsupported: …").Preserved legacy behaviour.
error (bare), any other codeFall back to legacy join (_legacy_join).Older gateway that doesn’t understand hello. Compatibility fallback #1.
joined (bare)Adopt legacy join → session bookkeeping populated, _negotiated=False, features/policy stay {}.Server treated hello as join. Compatibility fallback #2.
Anything elseRaise ConnectionError("Unexpected handshake reply: …").Guard against a broken server.

PayloadTooLarge

python
from praisonai_bot.gateway import PayloadTooLarge

Raised inside client.send(...), before the WebSocket write, when len(payload.encode("utf-8")) > client.policy["max_payload"].

  • The frame never leaves the client — nothing reaches the server and no round-trip is spent.
  • Size is measured in UTF-8 bytes, not len(str), matching how the server counts. Size payloads by encoded length, not string length.
  • Catch it and shrink/split the payload or accept the loss — retrying the same frame fails identically.
  • A max_payload of 0 is honoured as “reject everything”; None/missing means unbounded. On a legacy gateway (policy == {}) this exception is unreachable and the previous server-side reject still applies.

Policy Limits

KeyDefaultDescription
max_payload1048576 (1 MB)Maximum message payload size
max_buffered_bytes8388608 (8 MB)Maximum buffered bytes per connection
max_queued_frames1000Maximum queued outbound frames per client. Clients can read this to pace their own sends.
heartbeat_ms30000Heartbeat interval (heartbeat_interval * 1000, default 30 s)

Clients should self-configure from the policy object in hello_ok. See Gateway Flow Control for tuning max_buffered_bytes and max_queued_frames.


Persisted Session State

After hello_ok, the gateway records both the negotiated protocol version and the client-advertised capabilities on the GatewaySession. These values survive disconnect and resume.

python
import asyncio
from praisonai.gateway import GatewayClient

async def main():
    client = GatewayClient(
        url="ws://localhost:8765",
        agent_id="assistant",
        capabilities=["streaming", "ack"],
    )
    await client.connect()
    # After hello_ok, the server-side session exposes:
    # session.protocol_version  → "1"  (negotiated version string)
    # session.capabilities      → ["streaming", "ack"]  (client-advertised)

asyncio.run(main())
PropertyTypeDescription
session.protocol_versionstrThe negotiated protocol version agreed during hello.
session.capabilitieslist[str]The capabilities the client advertised in the hello message.

Both properties are written into session.to_dict() and restored by from_dict(), so they survive server restarts and session persistence backends.

Server-side hooks and custom routing code can read these off any live session:

python
def on_message(session, payload):
    if "streaming" in session.capabilities:
        # send token_stream events
        ...
    else:
        # send complete message at once
        ...

Best Practices

Always advertise protocol_min and protocol_max

Even when you only support version 1 today, send an explicit range so future servers can negotiate.

Only request capabilities you handle

Requesting streaming without handling token_stream events wastes bandwidth and confuses clients.

Use session_id + since on reconnect

Resume cleanly after disconnect and process replay envelopes before sending new messages.

Advertise only the capabilities you handle

Requesting streaming you don’t consume wastes bandwidth and the server advertises token_stream back at you — you’ll get events your loop drops silently.

Prefer client.supports_event(…) over try/except probing

The manifest is the source of truth once negotiated; probing races the server’s advertisement. Use client.supports_event("token_stream") to gate optional behaviour.

Catch PayloadTooLarge around client.send(…) for user-generated content

The local guard is the fast-fail seam; without a catch, a single oversized user message crashes the send loop.

Do not depend on client.features being populated on a legacy gateway

Fall through to supports_event(...)‘s permissive True default so a mixed-version fleet keeps working.

Branch on (code, next_step) from hello_error

Use the structured (code, next_step) pair instead of parsing message. The legacy next key is still emitted for older clients but new code should branch on next_step.

python
if frame.get("type") == "hello_error":
    step = frame.get("next_step")
    if step == "wait_then_retry":
        await asyncio.sleep(frame.get("retry_after_seconds", 1))
        await reconnect()
    elif step == "reauthenticate":
        await refresh_credentials()
        await reconnect()
    elif step in ("do_not_retry", "upgrade_client", "downgrade_client", "repair"):
        surface_to_user(frame["message"])
    else:
        await reconnect()  # default: try again

Related