Skip to main content

Client API

The Converger client API lives under /api/v1/converger. End-user clients such as web chat widgets and mobile apps use it. It is modelled on Bot Framework Direct Line: your backend exchanges a channel secret for a short-lived token, the client starts a conversation with that token, then posts activities over REST (or over the socket with postActivity) and receives them over the WebSocket at streamUrl (or by polling the activities endpoint with a watermark). Shared conventions (errors, rate limits, pagination, idempotency, CORS) are in the overview.

Controllers live in lib/converger_web/controllers/converger/ (module prefix ConvergerWeb.ConvergerAPI). Field names in this API are camelCase (conversationId, channelData), unlike the snake_case tenant API.

Endpoints​

MethodPathAuthPurpose
POST/api/v1/converger/tokens/generateBearer channel secretIssue an unscoped or channel-scoped converger token (backend only)
POST/api/v1/converger/tokens/publishableBearer publishable key, from an allowed originBrowsers without a backend: a token bound to a new (or resumed) conversation of an anonymous end user (publishable keys)
POST/api/v1/converger/tokens/refreshBearer converger tokenExchange a valid token for a fresh one (the old one is rotated)
POST/api/v1/converger/tokens/revokeBearer converger token, or channel secretRevoke a token and its family (logout), or every token of an end user (backend)
POST/api/v1/converger/conversationsBearer converger tokenStart a conversation; returns a conversation-bound token and streamUrl
GET/api/v1/converger/conversationsBearer channel-scoped converger tokenList the channel's conversations, most recently active first (agent consoles)
GET/api/v1/converger/conversations/:idBearer converger tokenReconnect: new token and streamUrl for an existing conversation
POST/api/v1/converger/conversations/:id/closeBearer converger tokenClose the conversation
POST/api/v1/converger/conversations/:id/reopenBearer converger tokenReopen the conversation
POST/api/v1/converger/conversations/:conversation_id/activitiesBearer converger tokenPost an activity
GET/api/v1/converger/conversations/:conversation_id/activitiesBearer converger tokenList activities after a watermark, or page backwards with a seq cursor; as activities or Protocol v1 frames
GET/api/v1/converger/conversations/:conversation_id/eventsBearer converger token or ?token=Server-Sent Events stream of Protocol v1 frames (fallback when WebSockets are blocked)
POST/api/v1/converger/conversations/:conversation_id/uploadBearer converger tokenUpload a file as a new activity (multipart)
GET/api/v1/converger/attachments/:idBearer converger tokenDownload an uploaded file

Authentication is described in client authentication. Every token-authenticated route also checks that the token's channel is still active (403 otherwise), that the token was not revoked (401 token_revoked), and that it has the permission the route needs (403 insufficient_permission, see Permissions). How tokens are signed, rotated and revoked is in Client tokens.

Flow​

Token scope​

TokenIssued byCan do
Unscoped (no conversation_id claim)tokens/generate, tokens/refresh of an unscoped tokenCreate conversations. Accepted for the conversations of its own channel (other channels' conversations get 404). Cannot join a conversation topic on the WebSocket.
Channel-scoped (scope: "channel", no conversation_id)tokens/generate with "scope": "channel", tokens/refresh of a channel-scoped tokenEverything an unscoped token can do, plus listing the channel's conversations (GET /conversations), plus, on the WebSocket (role agent), joining converger:channel:<channel_id> of a websocket channel to follow every delivery to it, and joining the conversations owned by or routed to its channel (agent console). See WebSocket.
Conversation-boundPOST /conversations, GET /conversations/:id, tokens/refresh of a bound tokenOnly its own conversation: other conversation ids get 403, attachments of other conversations 404.
tip

Give end users conversation-bound tokens. An unscoped token is not limited to one conversation, so exchange it for a conversation-bound one (POST /conversations) as early as possible, and do not persist it in the client.

Every token expires after 1800 seconds (expires_in). Refresh it with POST /tokens/refresh before it expires; an expired token cannot be refreshed, so the client has to get a new one from your backend. A token issued by POST /conversations or GET /conversations/:id never outlives the token that asked for it (expires_in says how long it has left): only a refresh extends a session, and a refresh rotates the token.

Permissions​

A token may be restricted with permissions on tokens/generate (the perms claim). The restriction is carried to every token derived from it (POST /conversations, GET /conversations/:id) or refreshed from it, and cannot be widened. A token without the claim has every permission.

PermissionAllows
readGET /conversations (channel-scoped tokens), GET /conversations/:id, GET .../activities, GET .../events (SSE), GET /attachments/:id; joining a conversation on the WebSocket (and with it ack, read receipts, sync)
writePOST /conversations (and starting one on the native WebSocket), POST .../activities, POST .../upload, close, reopen; postActivity and text frames on the WebSocket
typingTyping indicators on the WebSocket

A missing permission is answered 403 insufficient_permission with details.required (WebSocket: insufficient_permission, see Protocol v1). Example: a read-only viewer of a conversation gets {"permissions": ["read"]}.

The examples use:

export CONVERGER=http://localhost:4000
export CHANNEL_SECRET=... # backend only
export TOKEN=eyJhbGciOi... # converger token
export CONV=3f6d1c2e-8a4b-4f1e-9c51-0e6a2b7d9f10

Tokens​

Generate a token​

POST /api/v1/converger/tokens/generate

Called by your backend with the channel secret. Never call it from a browser or app: the secret would leak.

Rate limit: token_generate, 10 per minute per channel (shared with refresh). See rate limiting; raise it per tenant if your traffic needs more.

Body fieldTypeNotes
user.idstringOptional end-user id, stored as the user_id claim and carried through refreshes and conversation tokens. Used for the per-user socket id, so one user can be disconnected individually.
scopestringOptional. The only accepted value is "channel": the token gets the scope: "channel" claim and may join converger:channel:<channel_id> on the client socket (agent console). Conversation tokens minted from it (POST /conversations) do not carry the scope.
permissionsarray of stringsOptional. A non-empty subset of read, write, typing (see Permissions). Omitted: every permission.
curl -s -X POST "$CONVERGER/api/v1/converger/tokens/generate" \
-H "authorization: Bearer $CHANNEL_SECRET" \
-H "content-type: application/json" \
-d '{"user": {"id": "user-42"}}'

200 OK:

{ "conversationId": null, "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_in": 1800, "permissions": null }

permissions echoes the restriction (null: every permission).

StatusError (code: message)Cause
400bad_request: Token generation requires channel secret, not a tokenA converger token was sent instead of the secret
400bad_request: scope must be one of: channelUnknown scope value
400bad_request: permissions must be a ...permissions is not a non-empty list of known permissions
401unauthorized: Missing or malformed Authorization headerNo Bearer header
401unauthorized: Invalid or expired tokenUnknown secret, or the channel is inactive
429rate_limitedChannel's token_generate limit exceeded

To try a channel without the secret, an operator can issue the same token from the channel page's Client access panel (admin UI).

Refresh a token​

POST /api/v1/converger/tokens/refresh

Exchanges a still-valid token for a new one with a fresh 1800 s expiry, keeping its conversation_id (if any), user_id, scope, permissions and family. No body.

The presented token is rotated: it keeps working for the rotation grace period (default 30 s, so requests already in flight with it succeed) and is then rejected with 401 token_revoked. Use the new token from now on. Refreshing the same old token again within the grace period returns another token (two tabs refreshing at once); refreshing it after the grace period is treated as token theft: the whole family (every token derived or refreshed from the same generated token, the legitimate one included) is revoked and the answer is 401 token_revoked. The client then needs a new token from your backend.

curl -s -X POST "$CONVERGER/api/v1/converger/tokens/refresh" \
-H "authorization: Bearer $TOKEN"

200 OK:

{ "conversationId": "3f6d1c2e-8a4b-4f1e-9c51-0e6a2b7d9f10", "token": "eyJhbGciOi...", "expires_in": 1800 }

conversationId is null for an unscoped token. Errors: 401 (invalid or expired token, token_revoked), 403 (channel inactive), 429 (token_generate limit, per channel; tokens from a publishable key, claim src: "publishable", count against publishable_token instead).

Revoke tokens​

POST /api/v1/converger/tokens/revoke

Revocation takes effect on every node within a second, for REST and WebSocket alike: revoked tokens are refused (401 token_revoked, WebSocket close 4401) and the sockets of the token's subject (its end user, or its conversation) are disconnected; holders of other valid tokens of that subject reconnect normally.

CredentialBodyRevokes
Converger tokennoneThe token and its family: every token generated, derived or refreshed in the same chain. Use it on logout.
Channel secret (backend){"token": "..."}That token of the channel and its family. A token of another channel is 403; an invalid or expired token needs no revocation and is answered 200.
Channel secret (backend){"user": {"id": "user-42"}}Every token of that end user on the channel issued until now (a banned user). Tokens generated afterwards are valid.
curl -s -X POST "$CONVERGER/api/v1/converger/tokens/revoke" \
-H "authorization: Bearer $CHANNEL_SECRET" \
-H "content-type: application/json" \
-d '{"user": {"id": "user-42"}}'

200 OK: {"revoked": "user"} (or "token"). Errors: 400 (neither token nor user.id with the secret), 401, 403, 429 (token_generate limit, per channel).

Publishable keys​

POST /api/v1/converger/tokens/publishable

For browsers without a backend. The channel's publishable key (cvg_pk_..., safe in page source) gets a token bound to one conversation of an anonymous end user. The server creates the conversation and the user id (anon_...); the caller chooses neither. See publishable keys for what the key can and cannot do, and ADR-0074 for why. Implemented (#195).

The request must come from one of the key's allowed origins (Origin header). Browsers send it on their own.

Rate limits: publishable_token_ip, 10 per minute per client IP (checked first), then publishable_token, 300 per minute per channel (tenant override).

Body fieldTypeNotes
resumestringOptional. The resume of an earlier response: continue that conversation as the same end user.
curl -s -X POST "$CONVERGER/api/v1/converger/tokens/publishable" \
-H "authorization: Bearer $PUBLISHABLE_KEY" \
-H "origin: https://shop.example.com" \
-H "content-type: application/json" \
-d '{}'

200 OK:

{
"conversationId": "5b0c8f2e-3c1d-4a8e-9f3a-1d2e3f4a5b6c",
"token": "eyJhbGciOi...",
"expires_in": 1800,
"userId": "anon_q3Jt0m7oCw0b0FzQ1v2Y8A",
"resume": "SFMyNTY...",
"resumed": false
}

Keep resume (for example in localStorage) and send it on the next call, after a reload or when the token was rejected: the answer is a fresh token for the same conversation and user (resumed: true). An invalid or expired resume, one of another channel, or one whose conversation is closed starts a new conversation instead (resumed: false): replace what you stored. resume is valid for 7 days (PUBLISHABLE_RESUME_MAX_AGE).

The token carries conversation_id, user_id and src: "publishable". Use it like any conversation-bound token (activities, history, upload, the WebSocket) and refresh it with POST /tokens/refresh; other conversations of the channel answer 403.

StatusErrorCause
401unauthorized: Unknown or disabled publishable keyNo Bearer header, unknown or rotated key, key disabled, or channel inactive
403origin_not_allowedNo Origin header, or an origin the key does not allow
429rate_limitedpublishable_token_ip (per client IP) or publishable_token (per channel) exceeded; wait Retry-After

Conversations​

Start a conversation​

POST /api/v1/converger/conversations

Creates a conversation on the token's channel, with metadata: {"source": "converger"}. The optional body field title (a string of at most 200 bytes, chativa's createConversation(title)) is stored as metadata.title; anything else in the body is ignored, and a title that is not a string or is too long is a 400.

curl -s -X POST "$CONVERGER/api/v1/converger/conversations" \
-H "authorization: Bearer $TOKEN"

201 Created:

{
"conversationId": "3f6d1c2e-8a4b-4f1e-9c51-0e6a2b7d9f10",
"token": "eyJhbGciOi...",
"expires_in": 1800,
"streamUrl": "ws://localhost:4000/socket/converger/websocket?token=eyJhbGciOi...&conversation_id=3f6d1c2e-8a4b-4f1e-9c51-0e6a2b7d9f10"
}

token is bound to the new conversation; use it for all further calls. streamUrl is the WebSocket URL of the client socket with the token already in the query string. It is built from the scheme (wss for HTTPS requests, ws otherwise), host and port of the request as Phoenix sees it, so behind a reverse proxy that rewrites them you may need to build the URL yourself. See WebSocket for joining the channel and the frame format.

List the channel's conversations​

GET /api/v1/converger/conversations

The conversations of the token's channel, most recently active first, for agent consoles such as chativa's agent-panel mode (connecting clients, ADR-0065). It needs a channel-scoped token (tokens/generate with {"scope": "channel"}, called by your backend with the channel secret) with the read permission; an end-user or conversation-bound token gets 403 forbidden, because the list exposes every end user's conversations. Only the token's tenant and channel are listed.

Query paramNotes
limitDefault 50, max 500
cursorThe cursor of the previous page. An invalid cursor is a 400.
statusactive or closed
updated_sinceOnly conversations with activity at or after this time: ISO 8601 (2026-10-14T09:00:00Z) or milliseconds since the epoch
curl -s "$CONVERGER/api/v1/converger/conversations?status=active&limit=20" \
-H "authorization: Bearer $CHANNEL_TOKEN"

200 OK:

{
"conversations": [
{
"id": "3f6d1c2e-8a4b-4f1e-9c51-0e6a2b7d9f10",
"title": "Order ORD-42",
"contact": "Ayse Yilmaz",
"status": "active",
"lastMessage": "Where is my order?",
"lastMessageAt": 1791936076762,
"lastSeq": 17,
"metadata": { "source": "converger", "title": "Order ORD-42" },
"createdAt": "2026-10-14T09:12:03.004512Z",
"updatedAt": "2026-10-14T09:31:16.762118Z"
}
],
"cursor": "dHM6MjAyNi0xMC0xNFQwOToxMjowMy4wMDQ1MTJafDNmNmQ...",
"has_more": true
}
  • Order and paging are keyset on (updatedAt, id), descending. updatedAt moves with every activity, so a conversation that becomes active while you page can move to the first page; keep the list current from the socket rather than by re-paging.
  • Field names follow chativa's Conversation where one exists: title is metadata.title, else contact; contact is the participant's display name, else its external id (a phone number, for example); lastMessage is the text of the latest message (null when it was deleted or has no text) and lastMessageAt its time in milliseconds. status is Converger's: map active to chativa's open. An unread count is not computed; a client derives it from lastSeq and the seq it has read.
  • lastSeq is the head seq: pass it as hello.watermark to start a socket for that conversation at the present, or page the history backwards from it.

Reconnect to a conversation​

GET /api/v1/converger/conversations/:id

Returns a new conversation-bound token and streamUrl for an existing conversation, for example after a page reload. Pass watermark to have it appended to streamUrl, so the socket replays what the client missed.

Query paramNotes
watermarkOptional. The last watermark the client processed. Appended to streamUrl verbatim.
curl -s "$CONVERGER/api/v1/converger/conversations/$CONV?watermark=42" \
-H "authorization: Bearer $TOKEN"

200 OK:

{
"conversationId": "3f6d1c2e-8a4b-4f1e-9c51-0e6a2b7d9f10",
"token": "eyJhbGciOi...",
"expires_in": 1800,
"streamUrl": "ws://localhost:4000/socket/converger/websocket?token=eyJhbGciOi...&conversation_id=3f6d1c2e-8a4b-4f1e-9c51-0e6a2b7d9f10&watermark=42"
}

The conversation's status is not checked here: a closed conversation can still be reconnected to and read.

StatusError (code: message)Cause
403forbiddenToken bound to another conversation
404not_foundUnknown conversation, or one of another tenant

Close and reopen​

POST /api/v1/converger/conversations/:id/close POST /api/v1/converger/conversations/:id/reopen

Same semantics as the tenant API: idempotent, and a real transition appends a conversationUpdate activity from system that connected clients receive. A closed conversation rejects new activities and uploads with 409 until it is reopened (ADR-0017).

curl -s -X POST "$CONVERGER/api/v1/converger/conversations/$CONV/close" \
-H "authorization: Bearer $TOKEN"

200 OK:

{ "conversationId": "3f6d1c2e-8a4b-4f1e-9c51-0e6a2b7d9f10", "status": "closed" }

Errors: 403 (token bound to another conversation), 404 (unknown or foreign conversation).

Activities​

Activity shape​

ConvergerWeb.ConvergerAPI.ActivityJSON derives this shape from the canonical serializer, and the WebSocket activitySet frames use the same function, so REST and socket payloads are identical (ADR-0004):

{
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"seq": 42,
"type": "message",
"from": { "id": "user-42" },
"text": "Hi, I need help with my order",
"timestamp": "2026-10-09T12:05:41.004512Z",
"attachments": [],
"conversationId": "3f6d1c2e-8a4b-4f1e-9c51-0e6a2b7d9f10",
"channelData": {},
"replyToId": null,
"messageType": null,
"payload": null,
"editedAt": null,
"deletedAt": null
}
FieldCanonical field
seqseq: the per-conversation sequence number, the Protocol v1 watermark and the history cursor
from.idsender
timestampinserted_at (server time)
conversationIdconversation_id
channelDatametadata
replyToIdreply_to_id: the activity this one replies to, reacts to, edits or deletes
messageType, payloadmessage_type, payload: a typed message (buttons, card, image, tool_call, ...), null for a plain message (typed messages)
editedAt, deletedAtedited_at, deleted_at: set on a message once a messageUpdate / messageDelete for it was accepted. The message keeps its original content; apply the update yourself.

The shape is published as a JSON Schema, priv/protocol/v1/activity.schema.json. See activities for the rules.

seq and idempotency_key are not included; the position is carried by the watermark instead.

Post an activity​

POST /api/v1/converger/conversations/:conversation_id/activities

Rate limit: activity_create, 100 per second per tenant (shared with the tenant API).

HeaderNotes
x-idempotency-keyOptional. A repeated request with the same key returns the original activity's id and creates nothing. Not allowed by the CORS configuration, see CORS.
Body fieldTypeDefaultNotes
typestringmessagemessage, event, typing, messageReaction, messageUpdate, messageDelete, conversationUpdate, endOfConversation. Anything else (including the internal deliveryReceipt) is a 422.
textstringnullUp to 65,536 bytes. For messageReaction the emoji (at most 64 bytes).
attachmentsarray[]At most 10 objects, each at most 4,096 bytes as JSON, each with a required contentType (attachment schema). Use the upload endpoint for files.
replyToIdstringnullAn activity of this conversation. Required for messageReaction, messageUpdate and messageDelete.
channelDataobject{}Stored as metadata (16,384 bytes max). metadata is accepted as an alias when channelData is absent.
messageTypestringnullA typed message of the vocabulary (buttons, card, image, location, ...), a mekik-native type (tool_call, genui, interrupt, ...) or an open renderer name. Needs payload and type: message.
payloadobjectnullThe typed message as its frame body without type and seq, for example {"data": {"buttons": [...]}}. Validated against the Protocol v1 schemas (422 with per-field details); at most 131,072 bytes. text is filled from it when absent.
from.idstringthe token's user_id, else "user"Stored as the activity's sender

These are the only fields read from the body. The client changeset casts only type, text, attachments, metadata, reply_to_id, message_type and payload; tenant_id, conversation_id and the idempotency key come from the token and headers (ADR-0005). from.id is a display identity chosen by the client and is not verified against the token's user_id.

curl -s -X POST "$CONVERGER/api/v1/converger/conversations/$CONV/activities" \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-H "x-idempotency-key: 6c1f0a3e-msg-1" \
-d '{"type": "message", "from": {"id": "user-42"}, "text": "Hi, I need help with my order", "channelData": {"locale": "en-US"}}'

200 OK (Direct Line style resource response):

{ "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" }

The full activity arrives on the socket and in the activities list.

The same activity can also be sent over the socket with the postActivity event, which shares the activity_create bucket and replies with id, seq and watermark; see WebSocket.

StatusError (code: message)Cause
403forbiddenToken bound to another conversation
404not_foundUnknown or foreign conversation
409conversation_closedConversation is closed
422invalid_message with details {"type": ["is invalid"]}Validation failed: unknown type, sizes, an attachment without contentType (invalid_message with details {"attachments": ["attachment 0: contentType can't be blank"]}), or a bad reference (invalid_message with details {"reply_to_id": ["does not exist in this conversation"]}). Error keys use the stored field names.
429rate_limitedTenant's activity_create limit exceeded
503unavailableDelivery jobs could not be enqueued; nothing stored. Retry with the same idempotency key.

List activities​

GET /api/v1/converger/conversations/:conversation_id/activities

The activities after a watermark, oldest first (ADR-0006, ADR-0068). Use it to catch up after a reconnect, to fetch what the socket replay did not include, or to poll without a socket. It also pages backwards for "load older messages" (see below), and returns Protocol v1 frames with format=frames.

Query paramNotes
watermarkThe watermark of the previous response (the integer seq), the seq of the last v1 frame you processed, or the watermark of the last legacy Phoenix activitySet frame. Omit to start at the first activity.
beforeBackward paging: the activities with a seq below this one (a positive integer: the cursor of the previous backward page). Anything else is a 400.
directionforward (default) or backward. backward without before starts at the newest activity.
formatactivities (default) or frames: the page as Protocol v1 frames in frames
userIdWith format=frames and a token without user_id: the end user whose activities are from: "user"
limitDefault 100, max 1000
curl -s "$CONVERGER/api/v1/converger/conversations/$CONV/activities?watermark=41&limit=50" \
-H "authorization: Bearer $TOKEN"

200 OK:

{
"activities": [
{
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"type": "message",
"from": { "id": "bot" },
"text": "Sure, what is your order number?",
"timestamp": "2026-10-09T12:05:43.118020Z",
"attachments": [],
"conversationId": "3f6d1c2e-8a4b-4f1e-9c51-0e6a2b7d9f10",
"channelData": {}
}
],
"watermark": 42,
"has_more": false
}

Watermarks​

  • The watermark is the integer seq of the last activity in the response, a JSON number (42), the same number Protocol v1 frames carry as seq and watermark (issue #63). Before v3.0 it was an opaque string (URL-safe Base64 of seq:<n>, c2VxOjQy for seq:42); see migration below.
  • To resume, send the watermark back. The response contains activities with a higher seq only, so nothing is delivered twice and, because seq is gap-free and assigned under the conversation lock, nothing is skipped.
  • If has_more is true, call again immediately with the new watermark; otherwise you are caught up.
  • An empty page returns the position you sent, as an integer (null if you sent none).
  • REST and the sockets share the position: resume a v1 socket (hello.watermark) from a REST page's watermark, or page REST from the seq of the last v1 frame (for example the watermark of a replayTruncated frame). On the legacy Phoenix binding the join replays at most ws_replay_limit (default 100) activities in an activitySet frame with has_more; fetch the rest here starting from that frame's watermark.
  • Unlike the tenant API, an invalid watermark is not an error here: the list starts at the beginning of the conversation.

Watermark migration​

Since v3.0 (section 6.5 of the spec, ADR-0068) responses carry the integer. For one release the server still accepts every earlier form in ?watermark=: the integer, its decimal string, the opaque seq:<n> watermark and a legacy Base64 activity-id watermark (issued before seq existed). Opaque forms are logged as the deprecated opaque_watermark surface (telemetry [:converger, :deprecated, :use]) so operators can find the clients still sending them. Only the legacy Phoenix activitySet frame still emits the opaque string.

A client that stores the watermark and sends it back unchanged keeps working. A typed client must accept a number where it used to expect a string; the SDKs accept both. The release that stops accepting the opaque forms is announced in the changelog one release ahead.

History paging backwards​

A chat widget opens at the latest messages and loads older ones on scroll (chativa's loadHistory(cursor)). Ask for the newest page with direction=backward, then pass each response's cursor back as before (ADR-0065):

curl -s "$CONVERGER/api/v1/converger/conversations/$CONV/activities?direction=backward&limit=20&format=frames" \
-H "authorization: Bearer $TOKEN"
{
"frames": [
{ "type": "text", "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "seq": 81, "from": "user", "sender": { "id": "user-42", "role": "user" }, "data": { "text": "Where is my order?" }, "timestamp": 1791936076762 },
{ "type": "tool_call", "seq": 82, "data": { "id": "t1", "name": "get_order", "status": "completed" } }
],
"watermark": 82,
"has_more": true,
"cursor": "81"
}
  • The page is still oldest first: the activities with the highest seq below before (or the newest ones), so a client prepends it as it is.
  • cursor is the seq of the oldest activity of the page, as a string; send it as before for the next older page. It is null and has_more is false when the page reaches the first activity. In a backward page has_more means "older activities exist".
  • watermark is the watermark of the newest activity of the page, so the first backward page tells a client where the live stream starts.
  • Forward pages are unchanged and have no cursor.

With format=frames each activity is the frame the native WebSocket and the SSE stream deliver (a text or typed message frame with from relative to the token's user_id, tool_call, genui, interrupt, ...), rendered as on a replay. chativa feeds them to the same routing as live frames. Without format, the response is activities as before.

Errors: 400 (malformed before, direction or format), 403 (token bound to another conversation), 404 (unknown or foreign conversation).

Stream events (Server-Sent Events)​

GET /api/v1/converger/conversations/6f1c0e7e-1f0b-4a5e-9a39-2b7c6f0d9a11/events?watermark=41
Authorization: Bearer <conversation token>
Accept: text/event-stream
retry: 3000

id: 42
event: text
data: {"type":"text","id":"…","seq":42,"from":"bot","sender":{"id":"bot-1","role":"bot"},"data":{"text":"Hi!"},"timestamp":1750000000000}

event: heartbeat
data: {"type":"heartbeat","timestamp":1750000030000,"headSeq":42}

A fallback for networks that block WebSockets. Each event is a Protocol v1 server frame named by its type; persistent frames carry their seq as the event id.

  • The token may be passed as ?token= because the browser EventSource cannot set headers (query-string tokens are filtered from request logs). The stream is authorized like the activities endpoint, and the conversation must belong to the token's channel.
  • Resume: Last-Event-ID (sent automatically by a reconnecting EventSource) or ?watermark= (an integer seq or an opaque watermark); Last-Event-ID wins. No watermark replays the whole conversation (at most 10 000 frames, then a replayTruncated event: page the rest with the activities endpoint above). An invalid watermark starts from the beginning; one above the conversation head draws an error event invalid_watermark and the stream continues live.
  • A heartbeat event is sent every 30 s (heartbeat_interval_ms).
  • Receipts, typing and presence of the other participants arrive as deliveryStatus, typing and presence events (no id), as on the WebSocket. An identified viewer (token with user_id) counts as online for presence while the stream is open. Send read receipts and typing over a WebSocket; the stream is receive-only.
  • The stream ends after an error event token_expired when the token expires, channel_inactive when the channel is deactivated, or unavailable (with retryAfterMs) when the node shuts down. Reconnect with a fresh token (after token_expired) and the last seq. While a node shuts down, new streams are refused with 503 and Retry-After.
  • from is relative to the token's user_id (or ?userId=): that user's activities are user, everyone else's bot.
  • Send with Post an activity and an X-Idempotency-Key.

Errors: 401 (no or invalid token), 403 (token bound to another conversation), 404 (unknown conversation or one of another channel), 503 (the node is draining; retry after Retry-After seconds).

Uploads​

Upload a file​

POST /api/v1/converger/conversations/:conversation_id/upload

Stores a file and creates a message activity that references it, in one request. Storage backends (local disk, S3-compatible, GCS, Azure), CDN and the related environment variables are described in storage; the signing design is in ADR-0007.

Rate limit: upload, 10 per second per tenant.

Send multipart/form-data with these parts:

PartRequiredNotes
fileyesThe file. Its declared content type is ignored.
activitynoJSON string (or form map) with optional type, text, from.id, replyToId and channelData (or metadata) for the created activity. Invalid JSON is ignored.

Validation, in Converger.Uploads:

CheckDefaultConfiguration
Conversation is openChecked before the file is stored
Size10 MB (10,485,760 bytes)UPLOAD_MAX_BYTES / max_file_size. The multipart body as a whole may be at most this plus 1 MB.
TypeSniffed from the bytes, then checked against the allowlistUPLOAD_ALLOWED_TYPES / allowed_content_types; a tenant's allowed_upload_types replaces the global list for that tenant

Default allowlist: image/png, image/jpeg, image/gif, image/webp, application/pdf, video/mp4, video/webm, video/quicktime, audio/mpeg, audio/ogg, audio/mp4, audio/aac, audio/wav, text/plain, and the Office Open XML document, spreadsheet and presentation types. Content that cannot be identified is sniffed as application/octet-stream and rejected. Text is accepted as text/plain only when it is valid UTF-8 (an .html file is stored and served as text/plain).

curl -s -X POST "$CONVERGER/api/v1/converger/conversations/$CONV/upload" \
-H "authorization: Bearer $TOKEN" \
-F 'activity={"text": "Here is my receipt", "from": {"id": "user-42"}}'

200 OK:

{
"id": "e4d3c2b1-a0f9-4e8d-9c7b-6a5f4e3d2c1b",
"seq": 18,
"attachments": [
{
"id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"contentType": "image/png",
"contentUrl": "https://chat.example.com/api/v1/converger/attachments/9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"name": "receipt.png",
"size": 48213
}
],
"frame": {
"type": "text",
"id": "e4d3c2b1-a0f9-4e8d-9c7b-6a5f4e3d2c1b",
"seq": 18,
"from": "user",
"sender": { "id": "user-42", "role": "user" },
"data": {
"text": "Here is my receipt",
"attachments": [
{
"contentType": "image/png",
"contentUrl": "https://chat.example.com/api/v1/converger/attachments/9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"name": "receipt.png",
"size": 48213
}
]
},
"timestamp": 1791936076762
}
}

id and seq are the created activity's. frame is the attachment message as the WebSocket delivers it, rendered for the uploading end user (the token's user_id): chativa's sendFile can show it at once and de-duplicate the socket copy by id (connecting clients). The activity's sender is activity.from.id, else the token's user_id, else "user". Its attachments contain {"contentType", "contentUrl", "name", "size"} (name is the sanitized original filename), and its text defaults to "". contentUrl is built from the endpoint's configured URL (PHX_HOST in production), not from the request.

StatusError (code: message)Cause
400bad_request: Missing file in uploadNo file part
403forbiddenToken bound to another conversation
404not_foundUnknown or foreign conversation
409conversation_closedConversation is closed
413payload_too_large: File too large (max 10.0MB)File over max_file_size
413payload_too_largeWhole multipart body over max_file_size + 1 MB
415unsupported_media_type: File type application/octet-stream is not allowedSniffed type not in the allowlist
422invalid_messageThe activity failed validation (for example an unknown type); the stored file is deleted
429rate_limitedTenant's upload limit exceeded
502upstream_unavailable: File could not be stored, please retryStorage backend error

Attachments​

Download an attachment​

GET /api/v1/converger/attachments/:id

Serves a file uploaded through this API. The route does not require Accept: application/json, so clients can ask for image/* and similar. It needs the Authorization header like every client route, so a plain img tag cannot load it: fetch it with the header (and, for display, create an object URL from the response).

Access is tenant-scoped. An attachment of another tenant, or of another conversation when the token is conversation-bound, is reported as 404, as are unknown and malformed ids.

curl -s -L -o receipt.png "$CONVERGER/api/v1/converger/attachments/9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d" \
-H "authorization: Bearer $TOKEN"

The response depends on the storage backend:

BackendResponse
CDN configured302 Found, location is a CDN URL (signed where the CDN type supports it), cache-control: private, no-store
S3-compatible, GCS, Azure302 Found, location is a signed storage URL valid for signed_url_ttl (default 300 s, UPLOAD_SIGNED_URL_TTL), cache-control: private, no-store
Local disk200 OK, file streamed from the upload directory
Backend without signed URLs or local paths200 OK, bytes fetched from storage

A 200 response carries these headers:

HTTP/1.1 200 OK
content-type: image/png
content-disposition: inline; filename="receipt.png"; filename*=UTF-8''receipt.png
x-content-type-options: nosniff
content-security-policy: default-src 'none'; sandbox
cache-control: private, max-age=300
etag: "<sha256 of the file>"

content-type is the type sniffed at upload time (with charset=utf-8 for text/*). content-disposition is inline for images, video, audio and PDF, and attachment for everything else.

The signed URL itself carries no Converger authentication, so with curl use -L to follow it. Browsers follow the redirect automatically; for cross-origin storage, the bucket's own CORS settings apply.

StatusError (code: message)Cause
401unauthorized or token_expiredMissing, invalid or expired token
403channel_inactiveToken's channel inactive
404not_foundUnknown, malformed, foreign, or missing from storage
502upstream_unavailable: Attachment storage unavailableStorage backend error

Closed conversations​

Operation on a closed conversationResult
POST .../activities409 conversation_closed
POST .../upload409, same body, before anything is stored
Same idempotency key as an activity accepted before the close200 with the original id
GET .../activities, GET /conversations/:idAllowed
POST .../reopenReopens; activities are accepted again
WebSocket sendError reply conversation_closed (see WebSocket)

The close itself appears in the activity stream as a conversationUpdate activity from system with channelData.event set to conversation_closed (reason manual or expired). Conversations close automatically after 24 hours without activity by default.