> ## Documentation Index
> Fetch the complete documentation index at: https://docs.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](/websocket/acknowledgements) under a unique `event_id`.
* A durable write that [replaces state from a REST snapshot](/websocket/full-sync) 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`:

<CodeGroup>
  ```bash cURL theme={null}
  curl -sS -A "relay-docs/1.0" https://api.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.");
  }
  ```
</CodeGroup>

An empty list means the agent is ready. To move an agent from webhooks to WebSocket, read [choose the delivery path](/webhooks/subscriptions#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.relayapp.im/v1/websocket` with the token in the `Authorization` header:

<CodeGroup>
  ```bash cURL theme={null}
  # Inspect the upgrade response; use a WebSocket client for ongoing frames.
  curl -sS --http1.1 -i --no-buffer \
    'https://api.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.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.sender_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.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.relayapp.im
  Authorization: Bearer $RELAY_AGENT_TOKEN
  Connection: Upgrade
  Upgrade: websocket
  Sec-WebSocket-Version: 13
  Sec-WebSocket-Key: <generated by your WebSocket client>
  ```
</CodeGroup>

To run the TypeScript agent, run `npm install @relaymessenger/sdk 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](/live/sdks#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](/websocket/acknowledgements); an agent that keeps state rebuilds it in the FULL sync callback, as in [reconnect and recover](/websocket/full-sync).

<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.sender_handle.kind` is `"user"`, or stop after a set number of turns.
</Warning>

## What you get back

The upgrade answers `101`, and the first frame is [`ready`](/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 none, or exactly `observe=true`. 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](/websocket/protocol#when-it-fails) and the [recovery procedure](/websocket/full-sync).

## Next steps

* [Read the frames](/websocket/protocol)
* [Acknowledge events](/websocket/acknowledgements)
* [Reconnect and recover](/websocket/full-sync)
* [Observe events](/websocket/observe-events)
