Docs · Guides

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, 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
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
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 extra: pip install 'agentboxd[stream]'. The key needs the messages:read permission.

#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.
wscat
# 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
// 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.

MessageMeaning
→ { 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" }.
← helloconnection_id, the key’s scope, heartbeat_seconds and the replay window.
← subscribedThe 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 subscription: invalid_subscription (unknown event type) or inbox_not_found. The previous subscription stays.

Event types are the webhook ones: message.received, message.sent, message.delivered, message.bounced, message.complained, message.enriched, message.received.blocked, inbox.expired, and the drafts events draft.created, draft.updated, draft.sent, draft.failed and draft.cancelled. See the event catalog for example bodies.

#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. replay_truncated: true means some may be missing (the id is older than that, unknown, or more than 1000 events 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 client that stops reading while more than 1 MiB waits for it is dropped (4009); reconnect with since.
  • A revoked key or an ended session closes the stream within two minutes (4001).
CodeMeaningWhat to do
1001Server restartingReconnect with since
4001Missing, invalid, expired or revoked credential; or a key in the URLStop; get a new key or token
4003The key lacks messages:readStop
4008No subscription within 60 sSubscribe right after hello
4009Slow consumerReconnect with since, read faster
4029Too many streams for this workspaceClose some; reconnect later