SSE wire contract
POST /chat/control/stream answers with Server-Sent Events (text/event-stream): a long-lived response
that delivers the answer's text as it is written, plus structured events for the places, map actions and
routes in the answer.
This page is the contract for that stream. If you use kaleidr.js, the chat panel reads the stream for you and
you do not need this page. Read it when you build your own client. Working clients in Node and Python are in
Reading the stream; the request body is in
Endpoints.
/chat/summary/stream and /chat/button/stream use the same frame format with a smaller set of events. They
are described at the end of this page.
Before the stream starts
A request that is refused never becomes a stream. Authentication, scope, quota, rate-limit, validation and
size failures come back as an ordinary HTTP response with a JSON body and a 4xx status
(Errors). Check the status first, and parse the body as SSE only when it is 200.
The request has size limits: at most 400 messages, 64,000 characters in one message and 200,000 characters in
all messages together. A request over one of them is a 422 with a code, such as conversation_too_long, and a
body over 2 MiB is a 413. The full list is in Endpoints.
A successful response has these headers:
HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Frame format
A frame is one or two lines followed by a blank line. Lines end with \n.
event: place
data: {"name": "Louvre Museum", "coordinates": {"lat": 48.8606, "lng": 2.3376}}
data: {"text": "The Louvre sits on the right bank"}
: keep-alive
- A named frame has an
event:line, then onedata:line. - A prose frame has only a
data:line. It has noevent:line. - A comment starts with a colon. Kaleidr sends
: keep-aliveas a heartbeat. It carries no data. data:is always one complete JSON object on one line. Text is UTF-8.
Network chunks do not line up with frames. One read can deliver half a frame or several. Buffer what you receive, split on line breaks, and act on a frame only when you reach its blank line.
Compatibility rule
Ignore event names you do not recognize, and ignore fields you do not use. New events and new fields are added to this contract without a version change. A client that fails on an unknown event will break.
Stable events
event: | Payload | Meaning |
|---|---|---|
| (unnamed) | { text } | The next piece of the answer's prose. Append it to what you have. May contain [[Name]] markers around place names. |
phase | { type: "phase", phase: "thinking", session_id } | The request was accepted and work has started. phase is always "thinking". |
start | { type: "start", session_id, purpose: "control" } | The prose is about to stream. |
place | { name, coordinates?: { lat, lng }, address?, category?, icon?, source?, id?, unverified? } | A place in the answer. Merge by name; the first coordinates you receive for a name are final. source: "vendor" marks a place from your organization's own data; source: "map" marks a place the published map being viewed already shows. |
place_linked | place fields plus is_update? | More detail for a place already sent. Merge it into the place with the same name; ignore it if you have not seen that name. |
early_actions | { type: "early_actions", actions: [ { type, payload } ], places_count, is_partial: true } | Map actions you can apply before the answer finishes, sent once two or more places have coordinates. |
prompt_options | { type: "prompt_options", schema_version, kind, question, allow_free_text, options: [ { label, send_text } ] } | A question with choices, sent when the request was ambiguous. Show the options; send the chosen option's send_text as the next user message. |
route_clear | {} | Remove the routes you have drawn. New routes follow. |
route | { type: "route", schema_version, id?, profile, origin, destination, primary, alternatives[], comparison[], stops?, color?, label?, transit?, attribution? } | A computed route. Arrives before the prose on a directions question, so the map can draw while the text streams. |
end | { type: "end", full_text, places[], actions[], question_type, response_time, total_tokens?, dropped_places? } | The answer is complete. This is the authoritative result. |
error | { type: "error", message, code?, partial_text } | The answer failed. Sent instead of end. |
quota | { type: "quota", product, meter, tier, used, limit, reset_at, period } with a server key; { type: "quota", meter, reset_at } with a session token | Your organization's AI-credit usage after this call was charged. The last event of the stream, after end or error. See quota. |
: keep-alive | comment line | Heartbeat, sent after 10 seconds of silence. Ignore it, but count it as activity. |
Removed: grounding (2026-09-05). It is no longer sent.
Live-data cards and layers
A question about live or measured facts can add one of these events. A card arrives before the prose; a layer can arrive during or after it. They are additive: draw the ones you support and ignore the rest. Field sets beyond the ones listed can grow.
event: | Payload | Sent for |
|---|---|---|
weather_card | object with lat, lng, name, temperatures (temp_c, temp_f), conditions, optional forecast[] | Current weather at a place. |
weather_layer | object with layer, tile_path_template, tile_size, max_zoom, scope, attribution | A temperature overlay for a region. |
satellite_card | object with name, norad_id, lat, lng, altitude_km, velocity_kms, observed_at | The position of a named satellite. |
constellation_layer | object with group, features[] of { name, norad_id, lat, lng }, optional track | The satellites around the one in the card. |
building_card | object with lat, lng, optional name, height_m, num_floors | Facts about a building. |
trees_card | object with lat, lng, count, radius_m | Surveyed trees around a point. |
heritage_card | object with lat, lng, count, sites[] | Heritage sites around a point. |
rail_card | object with lat, lng, lines[], optional nearest_station | Rail lines around a point. |
arrivals_card | object with lat, lng, stop, departures[], alerts[], attribution | Transit departures at a stop. |
vehicles_layer | object with operator, vehicles[], shapes[], attribution | Transit vehicle positions for the card's stop or line. |
streetview_card | object with lat, lng, image_id, image_lat, image_lng | Street-level imagery near a place. |
end repeats five of these under the same name when they were sent, so a client that missed the event can still
read it: weather_card, satellite_card, constellation_layer, weather_layer and vehicles_layer (for
example end.weather_card). The other cards are not repeated on end. Live event listings are not delivered through the platform
API: see Data licenses.
Diagnostic / experimental (non-contract)
These can appear on the wire. They are not part of the contract: do not depend on them.
event: | Payload | Notes |
|---|---|---|
debug | { scope, … } | Internal diagnostic. Not sent on normal requests. |
place_name_found | { name, is_early } | An early hint that a place name was mentioned. The place event that follows is the one to use. |
Order of events
A stream moves through these stages. Stages marked optional do not appear in every stream.
- Optional.
placeevents withsource: "vendor": up to three places from your organization's own data, when the key carries thevendorscope. Sent first so the map can draw them at once. phase. Always the first event from the answer itself.- Optional.
: keep-alivecomments while the answer is prepared. - Optional. Events that precede the prose:
prompt_options, orroute_clearfollowed byroute, and a card. start.- Prose frames, interleaved with
place,place_linked,early_actions, and sometimesroute_clear,routeand layer events. Keep-alives fill any silence. - Optional. Late
place,place_linked, layer and route events, once the prose is finished. end, orerror.- Optional.
quota. - The server closes the connection.
Two consequences for a client:
endis not the last event. Keep reading until the body closes, or you will missquota.- A stream can close with neither
endnorerror, for example when the network drops. Treat a closed stream with no terminal event as failed.
Payload details
Prose and markers
Prose frames carry text only. Structured data never arrives inside the prose. The text can include
double-bracket markers around place names, such as near [[Louvre Museum]], which the chat panel turns into
links to pins. For plain display, remove the brackets and keep the name.
place
| Field | Type | Notes |
|---|---|---|
| name | string | The key you merge on. |
| coordinates | object { lat, lng } | Decimal degrees. Can be absent. A place the answer names but that could not be located is sent without coordinates and with unverified: true. Do not draw a pin for it. |
| address | string | Optional. |
| category | string | Optional. |
| icon | string | Optional. An emoji for the place. |
| source | string | Optional. "vendor" for a place from your organization's own data. "map" for a place that the published map the visitor is viewing already shows: the chat panel does not add its own pin for it, because the map draws it. |
| id | string | Optional. Present on places from your own data. |
| unverified | boolean | Optional. true when the place has no confirmed location. |
A place can carry other fields. Ignore the ones you do not use.
The same name can arrive more than once: first with coordinates, later with more detail. Keep one record per name, never move a pin once it has coordinates, and let later events fill in other fields.
Actions
early_actions.actions and end.actions are lists of { type, payload }.
| Action type | Payload | What to do |
|---|---|---|
| map.fitBounds | { coordinates: [[lng, lat], …], padding, maxZoom, duration } | Fit the camera to the coordinates. |
| places.highlight | { places: [ { name, lat, lng } ], options } | Emphasize these places. |
| map.showRoute | { id, label, profile, coordinates: [[lng, lat], …], distance_m, duration_s, turn_by_turn[], transit?, attribution? } | Draw the line. Show attribution with it. |
| route.clear | { id? } | Remove the route with that id, or all routes when there is no id. |
Other action types can appear. Skip the ones you do not handle. Note the two coordinate shapes: a place has
coordinates: { lat, lng }, while a line is a list of [lng, lat] pairs.
route
primary holds the line to draw: { coordinates: [[lng, lat], …], distance_m, duration_s, turn_by_turn[] }.
Each step in turn_by_turn has text and can have direction_type, distance_m, duration_s, lat and
lng. origin and destination each have a label. alternatives[] are other lines with a route_id, and
comparison[] gives each alternative's delta_duration_s against the primary. stops[], when present, are
ordered { lat, lng, name } points of a multi-stop route.
A road route carries attribution: { text, url, license }. Show text, linked to url, wherever you draw the
route. A transit route carries its operator's credit inside transit instead.
Each route event has an id. Drawing a route with an id you already have replaces that route.
end
| Field | Type | Notes |
|---|---|---|
| full_text | string | The complete answer. Prefer it over your concatenated prose frames. |
| places | array | The final place list, in order. Merge it over the places you collected, by name. |
| actions | array | The final map actions. Apply these; they supersede early_actions. |
| dropped_places | array of strings | Optional. Names sent earlier that are not part of the final answer. Remove their pins. |
| question_type | string | A label for the kind of question. Informational. |
| response_time | number | Seconds the answer took. |
| total_tokens | number | Optional. The tokens the answer used, which is what the call is charged. |
error
event: error
data: {"type": "error", "code": "upstream_stall", "message": "The AI service stopped responding. Please try again.", "partial_text": "The Louvre sits"}
message is safe to show to a user. partial_text is the prose produced before the failure. code is
present for one case only:
| Case | code | What to do |
|---|---|---|
| The answer stopped making progress, or the stream reached its maximum length | "upstream_stall" | Offer a retry. |
| Any other failure while answering | absent | Show message; a retry may succeed. |
After error there is no end. A quota event can still follow.
quota
The fields depend on the credential that opened the stream.
| Field | Type | Server key or legacy key | Session token |
|---|---|---|---|
| type | string, "quota" | Yes | Yes |
| meter | string | Yes | Yes |
| reset_at | string, ISO 8601 with offset | Yes | Yes |
| product | string | Yes | No |
| period | string | Yes | No |
| tier | string | Yes | No |
| used | number | Yes | No |
| limit | number | Yes | No |
A session token is what a page's visitors call with: one exchanged from a publishable key, or a published
map's visitor session. Its quota event says when the period resets and nothing about the organization's
plan, usage or limit. A browser client must not expect used or limit. The same rule applies to the 429
quota_exceeded body: see Quota and rate limits.
Keep-alives and timeouts
| Behavior | Value | What your client should do |
|---|---|---|
| Heartbeat on the chat stream | A : keep-alive comment after 10 seconds without other output | Reset your idle timer on any bytes received, comments included. |
| Idle connection | A connection silent for about a minute can be closed in transit | Set an idle (read) timeout of 30 to 60 seconds. Do not set a short total timeout. |
| Time to first prose | Can be tens of seconds on a hard question | Show progress from phase; do not treat the wait as a failure. |
| Maximum stream length | About 10 minutes, after which the server sends error with upstream_stall | Set a total timeout a little above 10 minutes as a backstop. |
Aborting, and how a stream is charged
Close the connection to abort. The server notices within about ten seconds and stops the answer.
- A stream that completes is charged the tokens the answer used, reported as
end.total_tokens. - A stream that is aborted or fails before completing is charged an estimate. It is at least the size of the conversation you sent in the request, and otherwise follows the amount of text already streamed. Because of the first part, it can be more than the hold placed when the request was admitted: closing the connection at the first frame does not make a long conversation free.
- Places from your own data (
source: "vendor") are not counted toward the charge.
A retry is a new request and is charged again. See Quota and rate limits.
Summary and button streams
These two endpoints back the place card in Kaleidr Chat. They use the same frame format, the same unnamed
prose frames and the same final quota event. They differ from the chat stream in three ways: they send no
phase event, no place or action events, and no keep-alives. Use a 60-second idle timeout.
Both refuse a request field that is too long with 422 field_too_long before the stream starts, and ignore
fields they do not list: see Endpoints. When one of these streams is aborted
or fails, the charge is an estimate of the text already streamed and is never more than the hold.
POST /chat/summary/stream, for a place:
- Optional.
addresswith payload{ type: "address", address, source } startwith payload{ type: "start", poi_type }- Optional, possibly more than once.
urlwith payload{ type: "url", website, source } - Prose frames with payload
{ text } endwith payload{ type: "end", full_text, summary, poi_type, category, city, country, sources[], website?, total_tokens? }, orerrorwith payload{ type: "error", message, partial_text }- Optional.
quota
POST /chat/summary/stream, for a building (type: "building"):
startwith payload{ type: "start", address }- Prose frames
endwith payload{ type: "end", full_text, parsed, total_tokens? }, orerror- Optional.
quota
POST /chat/button/stream:
startwith payload{ type: "start", query_type }- Prose frames
endwith payload{ type: "end", full_text, reply, query_type, first_token_ms, total_ms, total_tokens? }- Optional.
quota
The button stream has no error event. If it fails after it has started, the connection closes without end.
A failure before it starts is an HTTP 500 with {"detail": "Button chat stream error"}.
POST /spot/overview/stream sends the frames of a place summary, above, after a place frame with payload
{ type: "place", name }: the full name of the place or region described, such as
"Springfield, Ohio, United States of America", for labeling or copying it. It comes before every other frame
and can be absent. It is not the chat stream's place event and carries no coordinates. A ref it cannot describe gets
one error frame with payload { type: "error", message, ref } in place of the summary. See
Endpoints.
Related
- Reading the stream: complete Node and Python clients.
- Endpoints: request bodies for each stream.
- Errors: failures before and during a stream.
- Quota and rate limits: the
quotaevent and charging.