Realtime events over WebSocket

Get a hint the moment new mail arrives or an approval is decided, instead of polling. Connect, authenticate, ping, reconnect, and catch up.

View as MarkdownLast updated

The realtime connection sends hints: small events that say "something happened, go read it". They carry IDs and the risk level, never the sender, subject, or body. Fetch the details with the REST API or MCP, and treat what you read as outside data.

Events are best effort and sent at most once. Nothing missed while you're disconnected is sent again, so always catch up after reconnecting. If you need guaranteed delivery, use webhooks.

Connect

Read the URL from realtime.url in the discovery document or from GET /v1/agents/{agentId}/realtime (MCP get_realtime_connection). Don't hard-code it. The token needs the Read (messages:read) scope.

Send the token in one of two ways:

WhereValueUse it for
Authorization headerBearer atk_agent_…Servers and any client that can set headers. Preferred.
Sec-WebSocket-Protocol headeratmark.v1, bearer.atk_agent_…Browsers, whose WebSocket can't set headers. The server answers with atmark.v1 only.

Irreversible · Never put the token in the URL

A connection with a token-like query parameter is refused with 400. URLs end up in logs and browser history.

Node 22 (built-in WebSocket)
const info = await (await fetch(`https://api.atmark.ai/v1/agents/${agentId}/realtime`, {
  headers: { Authorization: `Bearer ${token}` },
})).json();

const ws = new WebSocket(info.url, ['atmark.v1', `bearer.${token}`]);
ws.onmessage = (frame) => {
  const event = JSON.parse(frame.data);
  if (event.type === 'pong' || event.type === 'subscribed') return;
  console.log(event.type, event.data);
};

If the handshake is refused, it ends with an HTTP status: 401 for a missing, wrong, or revoked token, 400 for a token in the URL, 403 for a missing scope or an inactive agent, and 429 when the agent already has 5 open connections. Don't retry a 401 or 403 with the same token.

Events

Every event has the same envelope.

message.received
{
  "id": "0f6c…a2d1",
  "type": "message.received",
  "api_version": "2026-10-08",
  "created_at": "2026-10-08T09:00:02.000Z",
  "agent_id": "7c21…f5a1",
  "data": {
    "message_id": "e4b2…77d0",
    "thread_id": "c1d0…5e9f",
    "received_at": "2026-10-08T09:00:01.000Z",
    "risk": { "level": "none" }
  }
}
typeWhendata
message.receivedA received email became visible to the agent: normal delivery, or the owner released held mail. Never sent for held or quarantined mail.message_id, thread_id, received_at, risk.level
approval.resolvedThe owner approved or rejected a held send. Approved mail is sent by Atmark; don't resend.approval_id, decision (approved or rejected), resolved_at, message_ids
  • id identifies the event. Ignore an id you've already handled.
  • Read the email with GET /v1/messages/inbound/{messageId} or read_email. If risk.level isn't none, treat it as hostile.
  • An unknown type may be added later. Ignore it.

Keep the connection alive

LimitValue
Open connections per agent5
Longest connection2 hours, then the server closes it
Idle timeout10 minutes without traffic
PingSend {"action":"ping"} about every 5 minutes; the answer is {"type":"pong"}.

{"action":"subscribe"} answers with the event types the connection receives. Every connection gets every type today, so this is optional.

Reconnect and catch up

Reconnect after any close, with exponential backoff and full jitter: start at 1 second and cap at 60 seconds. Open a new connection before the 2-hour limit if you can.

After reconnecting, list what you missed:

http
GET /v1/messages/inbound?agent_id={agentId}&since={last received_at you saw}

since is inclusive, so you may see one email again. Use the message id to skip it. Mail the owner released while you were away also shows up in this list.

The SDKs, once published, will do the ping, backoff, renewal, and header choice for you.

Feedback on this page? Write to support@atmark.ai.