Gateway Inbound Frame Codec

Validate every inbound WebSocket frame once with decode_client_frame into typed, discriminated client frames

python
from praisonaiagents import Agent

agent = Agent(name="gateway-agent", instructions="Handle validated inbound gateway frames.")
agent.start("Process a client message that arrived over the gateway wire contract.")

decode_client_frame is the single validating decode step at the WebSocket boundary. Instead of each handler hand-parsing data.get(...), you decode a raw JSON frame once into a typed, discriminated ClientFrame — or get a FrameDecodeError carrying a structured HelloError that is safe to serialise straight back to the client.

python
from praisonaiagents.gateway import (
    decode_client_frame,
    FrameDecodeError,
    HelloParams,
    MessageParams,
    LeaveParams,
    JoinParams,
)

frame = decode_client_frame({
    "type": "message",
    "content": "Book the meeting for 3pm.",
    "session_id": "abc-123",
})

assert isinstance(frame, MessageParams)
print(frame.content)      # "Book the meeting for 3pm."
print(frame.session_id)   # "abc-123"

The client sends a raw JSON frame; decode_client_frame validates it once and dispatches on type to a typed handler, or rejects it with a structured error.

Diagram

raw JSON frame

typed ClientFrame

FrameDecodeError

Client

decode_client_frame

Handler

hello_error / error

Quick Start

  1. Decode once at the boundary

    Swap per-handler data.get(...) for a single validating decode. Every advertised inbound method — hello, message, leave, and the legacy join — is decoded here into an already-typed object.

    python
    import json
    from praisonaiagents.gateway import (
        decode_client_frame,
        FrameDecodeError,
        HelloParams,
        MessageParams,
        LeaveParams,
        JoinParams,
    )
    
    async def on_ws_frame(raw_bytes, ws, agent, session_store):
        try:
            frame = decode_client_frame(json.loads(raw_bytes))
        except FrameDecodeError as e:
            await ws.send_json(e.error.to_dict())   # structured hello_error / error
            return
    
        if isinstance(frame, HelloParams):
            ...  # negotiate protocol — see Handshake Protocol
        elif isinstance(frame, MessageParams):
            await agent.handle_message(frame.content, session_id=frame.session_id)
        elif isinstance(frame, LeaveParams):
            await session_store.close(frame.session_id, reason=frame.reason)
        elif isinstance(frame, JoinParams):
            ...  # legacy path
  2. Reject invalid frames deterministically

    When a raw frame is not a mapping, carries an unknown or missing type, or fails per-frame validation, decode_client_frame raises FrameDecodeError. The attached HelloError maps straight onto the outbound hello_error (or error) wire frame via error.to_dict().

    python
    import json
    from praisonaiagents.gateway import decode_client_frame, FrameDecodeError
    
    try:
        decode_client_frame({"type": "message", "content": ""})   # empty content is rejected
    except FrameDecodeError as e:
        print(e.error.code)        # ConnectErrorCode.CONFIGURATION_ERROR
        print(e.error.next_step)   # ConnectRecoveryStep.DO_NOT_RETRY
        wire_frame = e.error.to_dict()
        print(json.dumps(wire_frame))   # ready to send back to the client

Inbound Frames

decode_client_frame returns one of four typed frames, discriminated on the wire type field:

python
ClientFrame = Union[HelloParams, MessageParams, LeaveParams, JoinParams]

HelloParams is documented on the Gateway Handshake Protocol page (the hello half of the same union). The codec adds two conveniences to the hello path: it tolerates both the flat protocol_min / protocol_max shape and the legacy nested protocol: {min, max} shape, and it accepts a capabilities / caps alias.

MessageParams

The validated message frame — previously hand-parsed per handler.

FieldTypeDefaultDescription
contentUnion[str, Dict[str, Any]]requiredThe message body — text, or a structured payload
session_idOptional[str]NoneOptional session the message belongs to
message_idOptional[str]NoneOptional client-supplied idempotency / correlation id
request_idOptional[str]NoneOptional request-idempotency key. A resend of the same id runs the turn exactly once — see Gateway Client → Exactly-Once Send
attachmentsList[AttachmentRef][]Optional first-class attachments (files) carried on the message turn — see Gateway Attachments. Each entry is either inline (data base64) or by reference (ref_id in the gateway attachment store), validated against the advertised policy
metadataDict[str, Any]{}Optional additional message metadata
typestr"message"Wire-type discriminant (set automatically)
json
{
  "type": "message",
  "content": "Book the meeting for 3pm.",
  "session_id": "abc-123",
  "message_id": "client-msg-001",
  "attachments": [
    {"filename": "note.txt", "mime": "text/plain", "size": 12, "data": "SGVsbG8sIHdvcmxkIQ=="},
    {"filename": "report.pdf", "mime": "application/pdf", "size": 4194304, "ref_id": "att_8f2c1a"}
  ],
  "metadata": {"user_id": "alice"}
}

LeaveParams

The validated leave frame.

FieldTypeDefaultDescription
session_idOptional[str]NoneOptional session the client is leaving
reasonOptional[str]NoneOptional human-readable reason (display / logging only)
typestr"leave"Wire-type discriminant (set automatically)
json
{"type": "leave", "session_id": "abc-123", "reason": "user closed tab"}

JoinParams

The legacy join handshake. New clients should prefer hello, but the codec keeps join in the same single validating decode step so the wrapper can share validation.

FieldTypeDefaultDescription
agent_idstrrequiredThe agent to connect to
min_versionint1 (MIN_CLIENT_PROTOCOL_VERSION)Minimum protocol version the client supports
max_versionint1 (GATEWAY_PROTOCOL_VERSION)Maximum protocol version the client supports
session_idOptional[str]NoneOptional session to resume
typestr"join"Wire-type discriminant (set automatically)
json
{"type": "join", "agent_id": "assistant", "min_version": 1, "max_version": 1}

The gateway also accepts a server_reply inbound frame — a client’s correlated answer to a blocked HITL turn’s server→client request. The reply’s value is validated per the request’s kind (approval → "allow"/"deny", choice → an offered option, input → any string). See Gateway Server→Client Request Channel for the full contract.

decode_client_frame covers the inbound contract only. The gateway also relays outbound stream events — token_stream, tool_call_stream, stream_end, reasoning_stream, tool_progress_stream, and stream_error — plus accepted vs final response frames after a client negotiates streaming. Those six event types and the response-frame states are documented in Gateway Stream Events.

How It Works

Field coercion and validation happen once, at the WebSocket boundary. Handlers receive already-typed objects; the transport gets a deterministic rejection.

Diagram
Typed Handlerdecode_client_frameWrapper ServerClientTyped Handlerdecode_client_frameWrapper ServerClientalt[valid frame][schema / version failure]raw JSON framedecode_client_frame(data)HelloParams / MessageParams / LeaveParams / JoinParamsdispatch typed frameresponseraise FrameDecodeError(HelloError)error.to_dict() → hello_error / error

Every rejection returns a FrameDecodeError whose .error is a HelloError with:

  • code — ConnectErrorCode.CONFIGURATION_ERROR for a schema failure, or ConnectErrorCode.PROTOCOL_UNSUPPORTED for a version-range failure
  • next_step — ConnectRecoveryStep.DO_NOT_RETRY for schema failures, or UPGRADE_CLIENT for version failures

The HelloError fields and full recovery-step vocabulary are documented on the Gateway Handshake Protocol and Gateway Error Handling pages — error.to_dict() produces the same wire shape used there.

Best Practices

Decode once at the boundary

Call decode_client_frame in one place — the WebSocket receive loop — and pass typed frames to handlers. Validation and coercion happen exactly once, so downstream code never re-checks types or defaults.

Return the attached HelloError on FrameDecodeError

On FrameDecodeError, send e.error.to_dict() straight back to the client. It is a safe, structured hello_error / error frame carrying (code, next_step, retry_after_seconds) — no per-handler try/except or ad-hoc isinstance checks needed.

Prefer decode_client_frame over per-handler data.get(…)

Hand-parsing each frame with data.get(...) drifts from the wire contract and duplicates validation. decode_client_frame is the single source of truth: it decodes every advertised inbound method plus the legacy join in one validating step.

Related