# WebSockets — real-time events without a public URL

Open a persistent connection to myagentmail and receive events as JSON
frames the moment they fire. Use this when the agent is **actively
waiting** for mail and you don't want to run a webhook receiver — the
canonical example is the **inline 2FA code flow**: an agent signs up
for a third-party service, waits for the verification email, extracts
the code, and submits it — all in one turn without any public URL.

The wire protocol is compatible with agentmail.to's WebSocket API, so
agents written against either SDK can use the same frame shapes.

## Endpoint

```
wss://myagentmail.com/v1/ws
```

Auth via the `X-API-Key` header (Node, Bun) or the `?api_key=<key>`
query parameter (browser `new WebSocket`, where you can't set headers).

There's also a legacy per-inbox alias: `wss://myagentmail.com/v1/inboxes/:id/ws`
— it auto-subscribes to the one inbox in the URL, so you can skip the
subscribe frame entirely. Use it for quick testing.

## Basic flow

```typescript
import WebSocket from "ws";

const ws = new WebSocket("wss://myagentmail.com/v1/ws", {
  headers: { "X-API-Key": process.env.MYAGENTMAIL_KEY! },
});

ws.on("open", () => {
  ws.send(JSON.stringify({
    type: "subscribe",
    event_types: ["message.received"],
    inbox_ids: [process.env.INBOX_ID!],
  }));
});

ws.on("message", (raw) => {
  const frame = JSON.parse(raw.toString());

  switch (frame.type) {
    case "connected":   console.log("connected as", frame.email); break;
    case "subscribed":  console.log("subscribed to", frame.inbox_ids); break;
    case "ping":        /* keepalive, optional: send a pong */ break;
    case "event":
      if (frame.event_type === "message.received") {
        console.log("new mail from", frame.message.from);
        console.log("subject:",       frame.message.subject);
        console.log("body:",          frame.message.plain_body);
      }
      break;
    case "error":       console.error("server error:", frame.message); break;
  }
});
```

```python
import asyncio, json, os, websockets

async def listen():
    key = os.environ["MYAGENTMAIL_KEY"]
    async with websockets.connect(
        "wss://myagentmail.com/v1/ws",
        additional_headers={"X-API-Key": key},
    ) as ws:
        await ws.send(json.dumps({
            "type": "subscribe",
            "event_types": ["message.received"],
            "inbox_ids": [os.environ["INBOX_ID"]],
        }))

        async for raw in ws:
            frame = json.loads(raw)
            if frame["type"] == "event" and frame["event_type"] == "message.received":
                print("new mail from", frame["message"]["from"])
                print("subject:",       frame["message"]["subject"])

asyncio.run(listen())
```

## Frame shapes

### Client → server

```jsonc
// Subscribe to events. Filters are optional; omit any to match everything.
{
  "type": "subscribe",
  "event_types": ["message.received", "message.sent", "message.bounced"],  // max 10
  "inbox_ids":    ["5157f9c5-..."],                                          // max 10
  "workspace_ids": ["2ceac37e-..."]                                          // max 10
}

// Optional keepalive pong (server ignores it, but it's there if your
// library is strict about replying to ping frames).
{ "type": "pong" }
```

The `workspace_ids` field also accepts `pod_ids` as a spec-compatible
alias (agentmail.to calls workspaces "pods"). Workspace-scoped API keys
(`wk_...`) are automatically locked to their own workspace — you can't
subscribe to a workspace outside your key's scope.

### Server → client

```jsonc
// Immediately after successful handshake
{ "type": "connected", "inboxId": "...", "email": "you@myagentmail.com" }

// Response to your subscribe frame
{
  "type": "subscribed",
  "event_types":   ["message.received"],
  "inbox_ids":     ["5157f9c5-..."],
  "workspace_ids": []
}

// Every time a matching event fires
{
  "type":       "event",
  "event_type": "message.received",
  "event_id":   "evt_01HXYZ...",           // unique per event, use for dedup
  "message": {
    "inbox_id":   "5157f9c5-...",
    "thread_id":  "6173425c-...",
    "message_id": "1c5d139e-...",
    "from":       "noreply@github.com",
    "to":         ["you@myagentmail.com"],
    "subject":    "Your one-time code is 483921",
    "plain_body": "Your code: 483921",
    "html_body":  "<p>Your code: <b>483921</b></p>",
    "timestamp":  "2026-04-11T09:12:34.000Z"
  },
  "thread": {
    "thread_id": "6173425c-...",
    "subject":   "Your one-time code is 483921"
  }
}

// Keepalive every 30s. Proxies between you and us will drop idle
// sockets around 60–120s; these pings keep the connection warm.
{ "type": "ping" }

// Error frame — sent when a subscribe filter is invalid, etc.
{ "type": "error", "message": "Forbidden inbox_id: ..." }
```

## The 2FA pattern

The flagship use case: an agent signs up for a third-party service,
waits for the verification email, extracts the code, submits it, all in
one turn. No public URL required.

```typescript
import WebSocket from "ws";
import { randomUUID } from "node:crypto";

async function getVerificationCode(siteUrl: string, timeoutMs = 120_000) {
  const KEY = process.env.MYAGENTMAIL_KEY!;
  const API = "https://myagentmail.com/v1";

  // 1. Create a fresh inbox just for this signup
  const inbox = await fetch(`${API}/inboxes`, {
    method: "POST",
    headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ username: `signup-${randomUUID().slice(0, 8)}` }),
  }).then((r) => r.json());

  // 2. Open a WebSocket subscribed to that inbox
  const ws = new WebSocket("wss://myagentmail.com/v1/ws", {
    headers: { "X-API-Key": KEY },
  });

  const codePromise = new Promise<string>((resolve, reject) => {
    const timer = setTimeout(() => {
      ws.close();
      reject(new Error("Timed out waiting for 2FA code"));
    }, timeoutMs);

    ws.on("open", () => {
      ws.send(JSON.stringify({
        type: "subscribe",
        event_types: ["message.received"],
        inbox_ids: [inbox.id],
      }));
    });

    ws.on("message", (raw) => {
      const frame = JSON.parse(raw.toString());
      if (frame.type !== "event") return;
      if (frame.event_type !== "message.received") return;

      // Match a 4–8 digit numeric code anywhere in the body or subject
      const text =
        (frame.message.subject || "") + " " + (frame.message.plain_body || "");
      const code = text.match(/\b\d{4,8}\b/)?.[0];
      if (code) {
        clearTimeout(timer);
        ws.close();
        resolve(code);
      }
    });

    ws.on("error", (err) => {
      clearTimeout(timer);
      reject(err);
    });
  });

  // 3. Fill the signup form with inbox.email (your browser automation)
  await fillSignupForm(siteUrl, inbox.email);

  // 4. Wait for the verification email to arrive
  const code = await codePromise;

  // 5. Submit the code and continue the signup
  await submitVerificationCode(siteUrl, code);

  return inbox; // hold onto the inbox if you'll need future mail for this account
}
```

## Scoping and filters

| Key type | What it can subscribe to |
|---|---|
| `tk_` tenant master | Every inbox in the tenant; can filter by `workspace_ids` and/or `inbox_ids` |
| `wk_` workspace master | Every inbox in its workspace; `workspace_ids` is pre-locked |
| `ak_` inbox scoped | Only its own inbox |

Filters combine with AND semantics — subscribing with
`{ event_types: ["message.received"], inbox_ids: ["a", "b"] }` means
"inbound events on either inbox a or b". Max 10 items in each list.

## Reconnection

Sockets aren't automatically retried. If the connection drops mid-turn,
you'll miss events that happen between the drop and your reconnect. If
that matters for your flow:

- **For inline agent turns** — just reconnect; the events you're
  waiting for will arrive after you re-subscribe. If it's been longer
  than your timeout, poll `GET /v1/inboxes/:id/messages?direction=inbound`
  to backfill anything missed.
- **For durable consumption** — use HTTP webhooks instead. They retry
  on your behalf. The two delivery paths are complementary: use
  WebSocket for the in-line agent turn and webhooks for durable
  storage in the same deployment.

## Keepalive

The server emits `{ "type": "ping" }` every 30 seconds. You don't have
to respond to it — it's just traffic to keep proxies and load balancers
from closing an idle socket. Most browser and Node WebSocket libraries
handle the underlying protocol-level ping frames automatically; the
JSON `ping` we emit is a separate application-level heartbeat that's
safe to ignore.
