Streaming
One WebSocket for live events and replies that render as they are written — the agent stream, its frames, limits, and how it relates to long-poll and webhooks.
What the stream is
wss://nmbr.ai/ws/agent/v1/stream is a third transport next to long-poll and webhooks: the same events, over one socket, with no polling latency — and one thing the others can't do: your agent can stream a reply, so the person watches the answer appear in their chat as your model produces it, the way they would with an assistant in a browser.
Authenticate at the upgrade with the agent token, either as ?token=agent:… or an Authorization: Bearer agent:… header. Every check that guards an HTTP call runs here too (a paused agent or a revoked token gets the same 403 / 401 and the usual { "error": { "code", "message" } } body instead of a socket). Optional ?afterSeq=<seq> replays events after that cursor; without it you start from now.
Frames
Both directions are JSON text frames of at most 64 KB.
Server → agent
| Frame | Meaning |
|---|---|
hello { seq } |
Connected; seq is your cursor. |
event { event } |
An event, in the same envelope as GET /updates (id, seq, type, ts, agentId, payload). |
pong |
Answer to your ping. |
reply.started { streamId, messageId, conversationId } |
Your reply's bubble is on the person's screen. |
reply.ended { streamId, messageId, message } |
Your reply is final; message is the stored message. aborted: true when withdrawn, idle: true when nmbr ended it for you. |
error { streamId?, error: { code, message } } |
A frame was refused. With streamId, it concerns that reply. |
Agent → server
| Frame | Meaning |
|---|---|
ping |
Keep-alive; nmbr also pings you every 30 s. |
typing { conversationId, typing } |
Show or clear the typing indicator (needs their messages:write). |
reply.start { streamId, conversationId | to } |
Open a reply. streamId is yours (≤ 64 chars, unique on this socket). |
reply.delta { streamId, text } |
Append text. The person sees it at once. |
reply.end { streamId } |
Finish the reply. |
reply.abort { streamId } |
Withdraw it — the bubble disappears. |
A streamed reply, step by step
reply.startpasses the same rules asPOST /messages: the person added your agent, grantedmessages:write, no block, and their replies-only window is open. A refusal is anerrorwith the usual code (not_a_contact,scope_not_granted,reply_window_closed,blocked) and is audited like any refused send. It counts as one message for the per-minute limit and the daily ceiling, however long it streams.- nmbr posts a placeholder bubble to the person's devices (no push yet) and answers
reply.started. - Each
reply.deltais pushed to their screen immediately and written to the database at most once a second, so a reload mid-stream shows the text so far. reply.endwrites the final text, sends the push notification once, and settles the bubble.reply.abort(or ending with nothing written) removes it.
const stream = agent.stream();
await stream.connect();
for await (const event of stream.events()) {
if (event.type !== "message.received" || !event.payload.message) continue;
const reply = stream.reply({ conversationId: event.payload.conversationId });
for await (const token of llm.stream(event.payload.message.content)) reply.write(token);
await reply.end();
}
The SDK's agent.stream() uses the platform WebSocket (Node 22+, Deno, Bun, browsers); on older Node pass { webSocket: WebSocket } from the ws package. Batch tokens if your model emits more than 20 per second — nmbr refuses deltas above that rate (rate_limited) rather than flooding the person's device.
Limits
3 connections per agent · 120 inbound frames per minute per connection · 20 deltas per second per reply · 3 open replies per connection · 10,000 characters per reply (the reply is ended for you at the cap) · a reply with no delta for 60 seconds is ended with the text so far · a connection that drops ends its open replies the same way. Over the cap the socket closes with code 1008 (rate) or 1009 (frame size).
Reconnecting
Nothing is lost: events live in the same queue the long-poll reads. Keep lastSeq (the SDK does) and reconnect with ?afterSeq= to resume; replies in flight when a socket dropped were already ended by nmbr, so start a new one.