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:

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+):

TypeScript
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.

ParamMeaning
eventscomma-separated ev_… ids. Empty = every event your plan allows
phrasescomma-separated normalized phrase keys; only phrase_detected matching one is sent (other types unaffected)
typescomma-separated event types; default: everything except transcript_delta. An unknown name is refused (validation_error frame listing the valid names, then 1008)
subjectscomma-separated typed-fact subjects (fact_extracted, see Live events)
contextopt 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:

JSON
{"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}
FieldMeaning
seqthe gateway's fan-out counter (per process). Not a resume cursor
tsserver clock, ms epoch
filtersthe filter the server applied (after dropping unknown types)
delay_msyour plan's delay: 60000 Free, 10000 Builder, 2000 Standard; 0 on a custom contract
planyour account's plan id (free, pro = Builder, team = Standard, enterprise), or anonymous
effective_planthe 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:

JSON
{"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:

FrameWhen
{"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)

JSON
{"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:

Python
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"))
TypeScript
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

CodeMeaningWhat the SDKs do
1000normal closestop (reconnect: false) or reconnect
1006 / 1011 / 1012network drop, server error, restartreconnect with backoff
1008policy: 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.streamTypeScript LangbotClient.stream
backoff1 s doubling to 30 s, ×0.5–1.5 jitter500 ms doubling to 15 s, ±20 % jitter
retriesuntil cancelled (reconnect=False to stop)maxRetries (default unlimited)
control framesswallowed; error frames loggedswallowed; error frames as onStatus({state: "server_error"})
invalid framesdropped (debug log)dropped, reported to onInvalid
filters on reconnectquery params + a subscribe opquery params + the last stream.subscribe()
SSE fallbackstream_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.