WebSocket
One connection for any number of events, filtered on the server, with a hello frame, resumable after a reconnect. SSE carries the same stream for networks that block WebSockets.
On this page
wss://api.langbot.ai/v1/stream. One connection carries any number of events; filter server-side with query params or a subscribe op. GET /v1/stream/sse is the same stream over Server-Sent Events for networks that block WebSockets.
Quick start
The SDKs handle the protocol, reconnects and validation. Python:
import asyncio
from langbot import LangbotClient
async def main() -> None:
lb = LangbotClient() # LANGBOT_API_TOKEN, else the token `langbot login` saved, else anonymous
async for evt in lb.stream(phrases=["tariff"], types=["phrase_detected"]):
verdict = evt.verdict.counts if evt.verdict else None
print(evt.event_id, evt.phrase, evt.t_spoken, "counts:", verdict)
asyncio.run(main())TypeScript (Node 20+):
import { LangbotClient } from "@langbotai/sdk";
const lb = new LangbotClient(); // LANGBOT_API_TOKEN when set, else anonymous
const stream = lb.stream(
{ phrases: ["tariff"], types: ["phrase_detected"] },
{ onStatus: (s) => console.error(s.state), onInvalid: (raw, err) => console.error("dropped", raw, err) },
);
for await (const evt of stream) {
console.log(evt.event_id, evt.phrase, evt.t_spoken, "counts:", evt.verdict?.counts ?? null);
}Without a token both run anonymously at the Free delay (+60 s), which is enough to see the protocol work.
Connect
GET /v1/stream?events=ev_a,ev_b&phrases=tariff,recession&types=phrase_detected,speaker_change
Authorization: Bearer lb_… (Node / server clients)Browsers cannot set headers on a WebSocket upgrade, so the token may also be passed as ?token=lb_…. Public events stream without a token at the Free delay (+60 s). With a token you get your plan's delay: Builder +10 s, Standard +2 s, real time on a custom contract (see Pricing). Concurrent stream connections (WebSocket + SSE together) are capped per plan: 1 on Free, 5 on Builder, 20 on Standard.
A token that does not resolve (typo, revoked) is refused: the server sends {"type":"error","code":"token_invalid","status":401,…} and closes with 1008. With no token at all the stream runs anonymously at the Free delay.
| Param | Meaning |
|---|---|
events | comma-separated ev_… ids. Empty = every event your plan allows |
phrases | comma-separated normalized phrase keys; only phrase_detected matching one is sent (other types unaffected) |
types | comma-separated event types; default: everything except transcript_delta. An unknown name is refused (validation_error frame listing the valid names, then 1008) |
subjects | comma-separated typed-fact subjects (fact_extracted, see Live events) |
context | opt in to context streams: on_air, topic, near_miss, typed, or all (none by default); unknown names are refused like types |
Frames (server → client)
All frames are JSON text, one object per message. Events and control frames share the socket; tell them apart by type.
hello, always first:
{"type":"hello","seq":48212,"ts":1793566860000,"filters":{"events":["ev_a"],"phrases":["tariff"],"types":[],"subjects":[],"context":[]},"delay_ms":60000,"plan":"anonymous","effective_plan":"free","user_id":null,"agent":null}| Field | Meaning |
|---|---|
seq | the gateway's fan-out counter (per process). Not a resume cursor |
ts | server clock, ms epoch |
filters | the filter the server applied (after dropping unknown types) |
delay_ms | your plan's delay: 60000 Free, 10000 Builder, 2000 Standard; 0 on a custom contract |
plan | your account's plan id (free, pro = Builder, team = Standard, enterprise), or anonymous |
effective_plan | the plan actually enforced: free when anonymous or when a subscription has lapsed |
Events: the event schema, with t_emitted set by the gateway and the receipt fields (hash, prev_hash, raw_hash, chain_seq) stamped before fan-out:
{"id":"evt_01M6CQ02PR3SVZ3TV7FCXH7E09","type":"phrase_detected","source_id":"src_example_public","event_id":"ev_kxexamplemention_26nov01","market_refs":["KXEXAMPLEMENTION-26NOV01-TARI"],"phrase":"tariff","raw":"we will talk about tariff today","speaker":{"label":"SPK_1","name":"Example Speaker","confidence":0.9},"confidence":0.93,"t_spoken":1793566809000,"t_detected":1793566809070,"t_emitted":1793566809076,"lag_vs_public_ms":null,"source_kind":"hls","seq":9,"hash":"aaaa…","prev_hash":"bbbb…","raw_hash":"cccc…","chain_seq":10,"fact":null,"class":"fact","verdict":{"counts":true,"reason":"mention 1 (1 needed)","role":null}}verdict is Langbot's reading of whether the hit counts under the market's rules (counts: null = unclear). It is not a settlement and not a trading signal.
Control frames:
| Frame | When |
|---|---|
{"type":"ping","ts":…} | after 20 s with nothing to send. No reply needed |
{"type":"subscribed","filters":{…},"seq":…} | after a subscribe op |
{"type":"pong","ts":…} | after a ping op |
{"type":"stats","queued":…,"dropped":…,"delivered":…} | after a stats op |
{"type":"error","code":"bad_request","status":400,"error":"…"} | bad JSON or an unknown op; the connection stays open |
{"type":"error","code":"validation_error","status":422,"error":"unknown types: …; valid types: …"} | a subscribe op naming an unknown type or context kind; the filter is left as it was |
{"type":"error","code":"token_invalid","status":401,…} / "validation_error" / "rate_limited" (429) | sent just before a 1008 close: the token did not resolve, the URL filter named an unknown type, or your plan's connection cap is reached |
code is the stable error code shared with REST (REST API errors); status is the HTTP status it corresponds to.
Treat any frame type you do not recognise as a new event type if it carries an evt_ id, and as a control frame otherwise; both SDKs do this, so a newer feed never breaks an installed one.
Ops (client → server)
{"op":"subscribe","events":["ev_c"],"phrases":["blackwell"],"types":["phrase_detected"]}
{"op":"ping"}
{"op":"stats"}subscribe replaces each filter key it names (events, phrases, types, subjects, context) and leaves the others as they are; send the full list you want. An empty list clears that filter, which for events means every event. There is no unsubscribe op: send subscribe with the shorter list.
Backpressure
Each connection has a bounded queue (1,000 events on the hosted feed). When it is full, a new transcript_delta or silence is dropped; a new phrase_detected (or any other type) first evicts the oldest queued transcript_delta/silence, and only if there is none, the oldest event. Phrase hits are therefore the last thing a slow client loses. Send {"op":"stats"} to see queued and dropped for your connection; a growing dropped means your consumer is too slow, so filter harder (types, events) or read faster.
Reconnect and resume
There is no server-side replay on the socket: hello.seq is a fan-out counter, not a cursor, and events published while you were disconnected are not re-sent. To fill a gap, ask the REST API for what you missed and dedupe on id:
import asyncio
from langbot import LangbotClient, LangbotError, StreamRefused
async def follow(event_id: str) -> None:
lb = LangbotClient()
seen: set[str] = set()
last_spoken = 0
backoff = 1.0
while True:
if last_spoken: # (re)connecting after a drop: fetch what was said meanwhile
for missed in await lb.recent_events(event_id, since=last_spoken):
if missed.id not in seen:
seen.add(missed.id)
print("backfilled", missed.phrase, missed.t_spoken)
try:
async for evt in lb.stream(
events=[event_id], types=["phrase_detected"], reconnect=False
):
backoff = 1.0
if evt.id in seen:
continue
seen.add(evt.id)
last_spoken = max(last_spoken, evt.t_spoken)
print(evt.phrase, evt.t_spoken)
except StreamRefused: # 1008: the connection cap; retrying cannot help
raise
except LangbotError: # a drop: back off, backfill, reconnect
pass
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30.0)
asyncio.run(follow("ev_kxexamplemention_26nov01"))import { LangbotClient } from "@langbotai/sdk";
const lb = new LangbotClient();
const seen = new Set<string>();
let lastSpoken = 0;
const stream = lb.stream(
{ events: ["ev_kxexamplemention_26nov01"], types: ["phrase_detected"] },
{
onStatus: async (s) => {
if (s.state !== "open" || !lastSpoken) return; // a reconnect: fetch what was missed
const missed = await lb.recentEvents({ eventId: "ev_kxexamplemention_26nov01", since: lastSpoken });
for (const e of missed) {
if (seen.has(e.id)) continue;
seen.add(e.id);
console.log("backfilled", e.phrase);
}
},
},
);
for await (const evt of stream) {
if (seen.has(evt.id)) continue;
seen.add(evt.id);
lastSpoken = Math.max(lastSpoken, evt.t_spoken);
console.log(evt.phrase, evt.t_spoken);
}GET /v1/events/recent?event_id=…&since=<ms> returns hits from since on (plan delay applies), newest first on the wire, up to 1,000 per page with a next_cursor for the rest; both SDKs return a page oldest first and page for you with iter_recent_events() / iterRecentEvents(). seq is monotonic per source; across sources, order by t_spoken. Duplicates are possible around a reconnect: always dedupe on id.
Close codes
| Code | Meaning | What the SDKs do |
|---|---|---|
| 1000 | normal close | stop (reconnect: false) or reconnect |
| 1006 / 1011 / 1012 | network drop, server error, restart | reconnect with backoff |
| 1008 | policy: the token did not resolve, the filter named an unknown type, or your plan's concurrent-connection cap (1 / 5 / 20) is reached (the error frame before it says which) | stop: Python raises StreamRefused, TypeScript reports {state: "closed", code: 1008, willRetry: false}. Close another stream or multiplex events on one connection with subscribe |
Client behaviour (what the SDKs do)
Python LangbotClient.stream | TypeScript LangbotClient.stream | |
|---|---|---|
| backoff | 1 s doubling to 30 s, ×0.5–1.5 jitter | 500 ms doubling to 15 s, ±20 % jitter |
| retries | until cancelled (reconnect=False to stop) | maxRetries (default unlimited) |
| control frames | swallowed; error frames logged | swallowed; error frames as onStatus({state: "server_error"}) |
| invalid frames | dropped (debug log) | dropped, reported to onInvalid |
| filters on reconnect | query params + a subscribe op | query params + the last stream.subscribe() |
| SSE fallback | stream_sse() | streamSSE() |
SSE frames carry the event type as the SSE event: and the same JSON as data:; idle keep-alives are SSE comments (: ping):
event: hello
data: {"type":"hello","seq":9032,"delay_ms":60000,"plan":"anonymous","effective_plan":"free",…}
event: phrase_detected
data: {"id":"evt_01M6…","type":"phrase_detected","phrase":"tariff",…}Measuring your own latency
t_emitted is stamped by the gateway; Date.now() - t_emitted on receipt is your network hop (plus your plan's delay), Date.now() - t_spoken is end-to-end from the word being finished. langbot watch prints the latter as +lag ms. Keep your clock NTP-synced or the numbers are meaningless.