# Realtime events

Source: https://agentboxd.com/docs/realtime-events

> The webhook events, pushed to your agent over a WebSocket: no public URL, reconnect and resume built in.

The event stream sends the same events as [webhooks](https://agentboxd.com/docs/webhooks), with the same bodies and ids, over one WebSocket your agent opens. It needs no public URL, so it works from a laptop, a job behind NAT or a browser tab. The SDKs reconnect on their own and pick up where they left off.

## With the SDKs

listen.ts:

```ts
import { Agentboxd } from 'agentboxd';

const mr = new Agentboxd();

for await (const event of mr.stream({ inboxIds: [inbox.id], eventTypes: ['message.received'] })) {
  const { message } = event.data as { message: { from: string; subject: string } };
  console.log(event.id, message.from, message.subject);
}
```

listen.py:

```python
import asyncio
from agentboxd import AsyncAgentboxd


async def main() -> None:
    async with AsyncAgentboxd() as mr:
        async for event in mr.stream(event_types=["message.received"]):
            print(event["id"], event["data"]["message"]["subject"])


asyncio.run(main())
```

TypeScript uses the built-in `WebSocket` (browsers, Node 22+; on Node 20 run `npm install ws`). Python needs the `stream` extra: `pip install 'agentboxd[stream]'`. The key needs the `messages:read` permission. Without `drafts:read` it gets draft events as envelopes (no draft body).

## Connecting

Open `wss://api.agentboxd.com/v1/stream` (`GET /v1/stream` with a WebSocket upgrade) and authenticate in one of two ways:

- **Header**: `Authorization: Bearer mr_…` on the upgrade request. For servers and CLIs.
- **Stream token**: `POST /v1/stream/token` returns `{ token, expires_at, url }`; connect to that `url` (`?token=st_…`). The token works once and expires after 60 seconds, so it can sit in a URL. For browsers, which can’t set headers. It keeps the key’s scope: a token from an inbox-scoped key only sees that inbox.

> **Careful:** API keys are never accepted in the URL (`?api_key=` is refused with close code 4001): URLs end up in proxy logs and browser history. Session cookies aren’t accepted either; the dashboard uses a stream token too.

wscat:

```bash
# Any WebSocket client: send the key in the Authorization header of the upgrade.
npx wscat -c wss://api.agentboxd.com/v1/stream -H "Authorization: Bearer $AGENTBOXD_API_KEY"
< {"type":"hello","connection_id":"…","scope":{"inbox_id":null},"heartbeat_seconds":30,"replay":{"max_events":1000,"max_age_seconds":3600}}
> {"type":"subscribe","event_types":["message.received"]}
< {"type":"subscribed","inbox_ids":null,"event_types":["message.received"],"payload":"full","replayed":0,"replay_truncated":false}
< {"type":"event","event":{"id":"…","type":"message.received","created_at":"…","data":{"inbox":{…},"thread_id":"…","message":{…}}}}
```

browser.ts:

```ts
// Server side (keeps the key): mint a single-use URL, valid for 60 seconds.
const { url } = await mr.streamToken(); // POST /v1/stream/token

// Browser: no key, no headers.
const ws = new WebSocket(url);
ws.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === 'hello') ws.send(JSON.stringify({ type: 'subscribe', payload: 'envelope' }));
  if (msg.type === 'event') console.log(msg.event.type, msg.event.data.subject);
};
```

## Messages

Every frame is a JSON object with a `type`. After `hello`, send one `subscribe`; sending another replaces it.

| Message | Meaning |
| --- | --- |
| → `{ type: "subscribe", inbox_ids?, event_types?, payload?, since? }` | Filters (omitted = everything the key can see), `payload: "full"` (default) or `"envelope"` (ids, addresses, subject, labels; no bodies), and `since` to replay missed events first. |
| → `{ type: "unsubscribe" }` · `{ type: "ping" }` | Stop events · keepalive, answered with `{ type: "pong" }`. |
| ← `hello` | `connection_id`, the key’s `scope`, `heartbeat_seconds` and the replay window. |
| ← `subscribed` | The active filters, `replayed` (events resent from `since`) and `replay_truncated`. |
| ← `{ type: "event", event }` | `event` is exactly the webhook body: `{ id, type, created_at, data }`. |
| ← `{ type: "error", code, message }` | A refused frame or subscription: `invalid_json` or `invalid_message` (a malformed frame), `invalid_subscription` (unknown event type), `inbox_not_found`, or `rate_limited` (`since` more than once in 5 seconds). The previous subscription stays. If you get a code that isn’t listed, reconnect with `since`. |

Event types are the webhook ones: `message.received`, `message.sent`, `message.delivered`, `message.bounced`, `message.complained`, `message.enriched`, `message.released`, `message.received.blocked`, `inbox.expired`, `inbox.paused`, `inbox.resumed`, `domain.dkim_rotated`, `domain.dkim_rotation_due`, `domain.verified`, `domain.failed`, `connector.authorized` and `connector.revoked` (the [hosted MCP connector](https://agentboxd.com/docs/mcp#hosted-connector-recommended)), `attachment.extracted` and `attachment.extraction_failed` ([document extraction](https://agentboxd.com/docs/document-extraction)), `identity.token_issued` and `identity.signed_in`, `signup.created` and `signup.claimed`, `workspace.stopped` and `workspace.resumed` (the [emergency stop](https://agentboxd.com/docs/emergency-stop)), `member.invited`, `member.invite_revoked`, `member.joined`, `member.role_changed` and `member.removed` (your team), `suppression.removed` ([your suppression list](https://agentboxd.com/docs/deliverability#your-suppression-list)), `escalation.sent` ([human on call](https://agentboxd.com/docs/human-on-call)), and the [drafts](https://agentboxd.com/docs/drafts) events `draft.created`, `draft.updated`, `draft.sent`, `draft.failed` and `draft.cancelled`. See the [event catalog](https://agentboxd.com/app/webhooks/events) for example bodies.

An inbox-scoped key gets the events of its inbox, live and on resume. Events about the whole workspace go only to workspace keys: team, domain, emergency stop and suppression list events, connector events of a connection to every inbox, and escalations that are not about one inbox.

## Resume after a disconnect

Remember the `id` of the last event you handled and subscribe with `since: <that id>`. The server first sends what you missed, in order, then live events, with no gap and no duplicates. It keeps the last hour, up to 1000 events and 4 MB of event data. `replay_truncated: true` means some may be missing (the id is older than that, unknown, or more than that followed it); fetch recent messages with the REST API to catch up. The SDKs do all of this for you.

## Limits and close codes

- The server pings every 30 seconds and drops connections that stop answering.
- Subscribe within 60 seconds of connecting, or the connection is closed (4008).
- Up to 20 open streams per workspace (4029 beyond that). A stream that closes during its replay counts until the replay ends.
- A client that stops reading while more than 1 MiB waits for it is dropped (4009); reconnect with `since`.
- Frames are answered one at a time; more than 8 waiting for a reply close the connection (1008).
- A revoked key or an ended session closes the stream within two minutes (4001). So does switching the session to another workspace, or leaving or being removed from the workspace.

| Code | Meaning | What to do |
| --- | --- | --- |
| `1001` | Server restarting | Reconnect with `since` |
| `1008` | Too many frames sent without waiting for replies | Wait for each reply before sending more |
| `1011` | Server error | Wait a moment, then reconnect with `since` |
| `4001` | Missing, invalid, expired or revoked credential; or a key in the URL | Stop; get a new key or token |
| `4003` | The key lacks `messages:read` | Stop |
| `4008` | No subscription within 60 s | Subscribe right after `hello` |
| `4009` | Slow consumer | Reconnect with `since`, read faster |
| `4029` | Too many streams for this workspace | Close some; reconnect later |
