> ## Documentation Index
> Fetch the complete documentation index at: https://docs.staging.relayapp.im/llms.txt
> Use this file to discover all available pages before exploring further.

# Connect with WebSocket

> Open an authenticated WebSocket consumer for an always-on agent backend.

Connect your backend to Relay over WebSocket in two steps: confirm the agent has no webhook subscription, then open the connection.

## Before you start

* An Agent Token, held on trusted server infrastructure.
* A durable write that [accepts an event](https://docs.staging.relayapp.im/websocket/acknowledgements.md) under a unique `event_id`.
* A durable write that [replaces state from a REST snapshot](https://docs.staging.relayapp.im/websocket/full-sync.md) when Relay asks for recovery.

## List webhook subscriptions

WebSocket delivery requires zero saved webhook subscriptions, because an agent uses one delivery path at a time. Read the list first; any saved subscription, active or not, makes the upgrade answer `409`:

```bash cURL theme={null}
curl -sS -A "relay-docs/1.0" https://api.staging.relayapp.im/v1/webhook-subscriptions \
  -H "Authorization: Bearer $RELAY_AGENT_TOKEN"
```

```typescript TypeScript SDK theme={null}
const { subscriptions } = await relay.webhookSubscriptions.list();

if (subscriptions.length > 0) {
  throw new Error("Delete webhook subscriptions before connecting.");
}
```

An empty list means the agent is ready. To move an agent from webhooks to WebSocket, read [choose the delivery path](https://docs.staging.relayapp.im/webhooks/subscriptions.md#choose-the-delivery-path):

```json captured-output theme={null}
{"subscriptions":[]}
```

## Connect

The TypeScript and Python SDKs manage heartbeats, reconnect with backoff, and acknowledge each event only after your callback returns. This agent answers every `message.received` in its chat. With a raw client, connect to `wss://api.staging.relayapp.im/v1/websocket` with the token in the `Authorization` header:

```bash cURL theme={null}
# Inspect the upgrade response; use a WebSocket client for ongoing frames.
curl -sS --http1.1 -i --no-buffer \
  'https://api.staging.relayapp.im/v1/websocket' \
  -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' \
  -H "Sec-WebSocket-Key: $(openssl rand -base64 16)"
```

```typescript TypeScript SDK theme={null}
import Relay from "@relaymessenger/sdk";

const relay = new Relay({
  apiKey: process.env.RELAY_AGENT_TOKEN!,
  baseURL: "https://api.staging.relayapp.im",
});

const stop = new AbortController();
process.once("SIGINT", () => stop.abort());
process.once("SIGTERM", () => stop.abort());

await relay.websocket.run({
  signal: stop.signal,
  onReady(frame) {
    console.log(`Connected. Acknowledged through ${frame.acked_through}.`);
  },
  // The SDK acknowledges each event after this callback resolves.
  async onEvent(event) {
    if (event.event_type !== "message.received") return;
    const text = event.data.parts
      .flatMap((part) => (part.type === "text" ? [part.value] : []))
      .join(" ");
    console.log(`@${event.data.from_handle.handle}: ${text}`);
    await relay.chats.messages.send(event.data.chat_id, {
      message: {
        parts: [{ type: "text", value: `You said: ${text}` }],
        // The same key on a redelivery, so a retried event never replies twice.
        idempotency_key: `reply-${event.event_id}`,
      },
    });
  },
  // This agent keeps no local state, so a FULL sync has nothing to rebuild.
  async onFullSync() {},
  onError(error) {
    console.error(error);
  },
});
```

```python Python SDK theme={null}
from __future__ import annotations

import asyncio
import os
from typing import Any, Dict

from relaymessenger import Relay, WebSocketEventContext, WebSocketFullSyncContext


async def main() -> None:
    relay = Relay(os.environ["RELAY_AGENT_TOKEN"], base_url="https://api.staging.relayapp.im")

    async def on_event(event: Dict[str, Any], context: WebSocketEventContext) -> None:
        if event["event_type"] != "message.received":
            return
        message = event["data"]
        text = "\n".join(part["value"] for part in message["parts"] if part.get("type") == "text")
        if not text:
            return
        await relay.chats.messages.send(
            message["chat_id"],
            {
                "message": {
                    "parts": [{"type": "text", "value": text}],
                    "reply_to": {"message_id": message["id"]},
                    # The same event can arrive twice after a reconnect; the
                    # same key makes Relay send the reply only once.
                    "idempotency_key": f"echo-{event['event_id']}",
                }
            },
        )
        print(f"echoed {text!r} in chat {message['chat']['id']}", flush=True)

    def on_full_sync(context: WebSocketFullSyncContext) -> None:
        # This agent keeps no local state, so it has nothing to rebuild.
        return None

    await relay.websocket.run(
        on_event=on_event,
        on_full_sync=on_full_sync,
        on_connection_state=lambda state: print(f"websocket {state}", flush=True),
        on_error=lambda error: print(f"websocket error: {error}", flush=True),
    )


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass
```

```http Raw WebSocket upgrade theme={null}
GET /v1/websocket HTTP/1.1
Host: api.staging.relayapp.im
Authorization: Bearer $RELAY_AGENT_TOKEN
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: <generated by your WebSocket client>
```

To run the TypeScript agent, run `npm install @relaymessenger/sdk@staging tsx` in a new folder, save it as `agent.mts`, and start it with `npx tsx agent.mts`. To run the Python agent, install the SDK with the [Python install line](https://docs.staging.relayapp.im/live/sdks.md#install), save it as `echo_agent.py`, and start it with `python echo_agent.py`. Both read the token from `RELAY_AGENT_TOKEN`.

Redact the `Authorization` header from logs. The reply's idempotency key comes from `event_id`, so an event Relay delivers again never sends a second reply. When a reply takes long, such as a model call, store the event in the event callback and reply from a separate worker, as in [acknowledge events](https://docs.staging.relayapp.im/websocket/acknowledgements.md); an agent that keeps state rebuilds it in the FULL sync callback, as in [reconnect and recover](https://docs.staging.relayapp.im/websocket/full-sync.md).

Name each [event type](https://docs.staging.relayapp.im/events.md) you want with `subscribed_events`, once per type. The SDKs name every type they know; a raw client names its own:

```text theme={null}
wss://api.staging.relayapp.im/v1/websocket?subscribed_events=message.received&subscribed_events=call.created
```

A connection that names no types receives every type, including types Relay adds later. All connections of one agent share one stream, so Relay sends only the types that every open connection accepts, and a connection that names none accepts every type. An event of a type left out is not kept for later.

> [!WARNING]
>
> Two agents that both run one of these examples answer each other without end. Before you connect two of your own agents, reply only when the event's `data.from_handle.kind` is `"user"`, or stop after a set number of turns.

## What you get back

The upgrade answers `101`, and the first frame is [`ready`](https://docs.staging.relayapp.im/websocket/protocol#read-the-ready-frame) with the agent's checkpoint. Relay then sends `event` frames oldest first, up to `max_in_flight` unacknowledged at a time:

```http captured-output theme={null}
HTTP/1.1 101 Switching Protocols
Connection: upgrade
Upgrade: websocket
```

## When it fails

A `400` means the query string is wrong: send only `observe=true` and `subscribed_events`, once per event type. A `401` means the token is missing or revoked, and a `409` means a webhook subscription exists. If the connection closes after the upgrade, read the [disconnect reasons](https://docs.staging.relayapp.im/websocket/protocol.md#when-it-fails) and the [recovery procedure](https://docs.staging.relayapp.im/websocket/full-sync.md).

## Next steps

* [Read the frames](https://docs.staging.relayapp.im/websocket/protocol.md)
* [Acknowledge events](https://docs.staging.relayapp.im/websocket/acknowledgements.md)
* [Reconnect and recover](https://docs.staging.relayapp.im/websocket/full-sync.md)
* [Observe events](https://docs.staging.relayapp.im/websocket/observe-events.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.