Converger Protocol v1 (converger/1)
Status: draft for review (#21). Normative once merged. The protocol is a superset profile of mekik/1 (decision recorded in #63 and ADR-0024): it is identical to mekik/1 wherever mekik/1 defines something, and additive where the hub needs more. The rich message vocabulary is in messages.md. JSON Schemas (draft 2020-12) for every frame live in
priv/protocol/v1/and ship with every release. The TypeScript SDK@converger/client(#46) implements this protocol on the native endpoint, the Phoenix binding and the SSE fallback.
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as in RFC 2119.
Every feature is marked with its status:
- Implemented: the server does this today.
- Planned (#N): specified here, delivered by issue #N. Until then a client MUST NOT rely on it. Server implementation issues use the frame and field names from this document.
1. Model and principles
- Persist first, then push. Every persistent frame a client receives was derived from a
committed activity with a
seq. The socket is a cache of the database, never the source of truth. - One ordered log per conversation. Persistent frames carry a 1-based, per-conversation,
strictly monotonic
seqwith no gaps (mekik/1 section 2). A client'swatermarkis the highestseqit has durably seen. Reconnecting with a watermark replays exactly what is missing. - Client ids and acks give at-least-once sending and exactly-once persistence.
- Receivers ignore what they do not understand. Unknown fields and unknown frame types MUST be ignored (mekik/1 preamble), so additive changes never break an older peer.
- Bounded everything: frame size, in-flight sends, replay batches, conversations per socket.
Identity (mekik/1 section 1):
| id | lifetime | meaning in Converger |
|---|---|---|
userId | permanent | the end user. Verified when the token carries user_id. |
conversationId | until deleted | a Converger conversation (UUID). |
connectionId | one socket | a routing handle for one live connection. |
watermark | per client | the highest persistent seq the client has durably seen. |
A conversation may have many live connections at once (tabs, devices, agents, the bot channel). Every persistent frame is delivered to all of them, except that a user's own turn is not echoed to the connection that sent it (section 6.4).
2. Transports and version negotiation
Frames are JSON objects with a type discriminator, one frame per WebSocket text message,
UTF-8. Binary WebSocket messages carry MessagePack and are accepted only when the
converger.v1+msgpack subprotocol was negotiated (section 2.1); otherwise they are answered
with error bad_request. Implemented (#26) on the native endpoint.
Backend clients can also speak v1 over a gRPC bidirectional stream (converger.v1.ChannelStream/Connect), with the
frames as protobuf messages and the same semantics as the native endpoint: Protocol v1 over gRPC
(Implemented (#44)).
2.1 Native endpoint: Implemented (#26)
wss://HOST/socket/converger/v1
Raw frames exactly as in this document, which makes the endpoint wire-compatible with mekik/1
clients such as @chativa/connector-mekik. A connection serves one conversation (channel-scoped
connections are section 10). A plain GET without the upgrade headers is answered with
HTTP 426.
-
A client SHOULD offer the WebSocket subprotocol
converger.v1(Sec-WebSocket-Protocol: converger.v1). The server selects the first offered subprotocol it supports, so clients list them in order of preference:subprotocol encoding WebSocket messages converger.v1JSON text converger.v1+jsonJSON (explicit alias of converger.v1)text converger.v1+msgpackMessagePack: the same frame maps, re-encoded binary (text JSON frames are also accepted) -
A client that offers no subprotocol is accepted and spoken to as
converger/1in JSON with mekik/1 semantics (mekik/1 defines no subprotocol). This is how unmodified mekik/1 clients connect. Unless itshellonamesprotocol: "converger/1", such a connection gets the mekik/1 profile (section 14.1): no Converger-only transient frames, heartbeats as WebSocket pings (Implemented (#65)). -
A client that offers subprotocols, none of which the server supports, is refused during the upgrade (HTTP 400). A future
converger.v2is offered next toconverger.v1(section 13). -
Identity hints MAY travel in the query string (
?conversationId=...&userId=...) for sticky routing by an L7 load balancer, as in mekik/1. They are hints;helloand the token decide. -
Inbound frames above
limits.maxFrameBytesdrawpayload_too_large; above the hard cap (1 MiB) the server closes the socket with 1009. -
The client WebSocket limits of the Phoenix sockets apply (WebSocket limits and draining): frames above the per-connection rate draw
rate_limitedwithretryAfterMs; a client that does not read fast enough losestypingandpresenceframes first and is then closed with 4503; while the node drains new connections get HTTP 503 withRetry-After, and open ones are closed with 1012 (section 12.3). -
A runnable client using only Python's
websocketslibrary is inexamples/python/converger_ws.py.
2.2 Phoenix Channels binding
wss://HOST/socket/converger/websocket?token=TOKEN&vsn=2.0.0
topic: converger:conversation:CONVERSATION_ID
vsn is the Phoenix serializer version, not the protocol version. The protocol version is
chosen in the join payload:
- Implemented (legacy shape): a join without
protocolgets the pre-v1 frames: the join payload may carry awatermark(the opaque one of a previousactivitySet, or the integer seq); the server pushesactivitySetevents{activities, watermark, has_more}(replay, then one per live activity). See section 13.3. The client sends with the eventpostActivity(an activity with an optionalclientId; the reply is{id, seq, watermark}or{reason}), Implemented (#23); it is part of the pre-v1 shape and is replaced by v1 message frames andack. - Implemented (#24), on any join: a client sends v1
textframes with the eventframe(section 7). Theackorerrorcomes back as aframeevent; the Phoenix reply isok, only a transport receipt. Other client frame types drawerrorbad_request(typing and read receipts keep their own events on this binding), andframeis refused onconverger:channel:*topics ({"reason": "bad_request"}). On a join withoutprotocolthe sender also receives its own turn in anactivitySet, after itsack. - Planned (#22): a join payload
{"protocol": "converger/1", ...hello fields}switches the topic to v1. Every server frame is then pushed as the Phoenix eventframewhose payload is the complete frame (includingtype); clients push frames with the eventframetoo. The Phoenix reply to a client push is only a transport receipt: the protocol answer is theackorerrorframe. The Phoenix heartbeat (topicphoenix) replaces theheartbeat/pingframes on this binding. Long-polling (/socket/converger/longpoll) is enabled as a fallback for networks that block WebSockets (Implemented (#26), section 2.4).
2.3 Legacy socket: Implemented, deprecated (#23)
/socket/websocket (ConvergerWeb.UserSocket), topic conversation:CONVERSATION_ID, events
new_activity / replay_truncated, resume by last_activity_id, authenticated with a legacy conversation token.
It does not speak v1, logs a deprecation warning per connection and is removed according to the deprecation
policy (section 13.3). See migrating from the legacy API.
2.4 Fallbacks for networks without WebSockets: Implemented (#26)
Some proxies block WebSockets. Two fallbacks exist, in order of preference:
-
Server-Sent Events for receiving, REST for sending:
GET https://HOST/api/v1/converger/conversations/CONVERSATION_ID/events?watermark=NAccept: text/event-streamEvery event is a v1 server frame (the same JSON as on the native endpoint) with the frame
typeas the SSE event name. Persistent frames carry theirseqas the SSEid, so a reconnectingEventSourcesendsLast-Event-IDand resumes exactly after it;Last-Event-IDtakes precedence over?watermark=. Replay, de-duplication, gap filling,replayMaxandreplayTruncatedare as in section 6; receipts, typing and presence (section 8) are streamed as well. The server sendsheartbeateveryheartbeatIntervalMs, and ends the stream witherrortoken_expiredat token expiry,channel_inactivewhen the channel is deactivated, orunavailable(withretryAfterMs) when the node drains; while it drains, new streams get HTTP 503 withRetry-After. The credential is theAuthorization: Bearerheader or, becauseEventSourcecannot set headers,?token=. Turns are sent withPOST /api/v1/converger/conversations/ID/activities(X-Idempotency-Keygives the same exactly-once persistence as aclientId). Nowelcomeis sent; the frames are rendered for the token'suser_id(or?userId=), asfromis relative to the end user. -
Long-polling on the Phoenix binding (
/socket/converger/longpoll, section 2.2): thephoenixJavaScript client falls back to it automatically when the WebSocket fails.
3. Authentication
3.1 Tokens
Clients authenticate with a Converger token (JWT, type: "converger"), issued by
POST /api/v1/converger/tokens/generate (channel secret) and renewed by
POST /api/v1/converger/tokens/refresh. Implemented claims:
| claim | meaning |
|---|---|
type | always converger |
tenant_id, channel_id | the channel the token belongs to; the channel must be active |
sub | converger_CHANNEL_ID |
exp | expiry; default lifetime 1800 s |
conversation_id | optional: restricts the token to one conversation |
user_id | optional: the verified end user; also the socket identity (ConvergerWeb.Sockets) |
scope | optional: channel (agent and bot-channel sockets, section 10). Implemented for tokens/generate ({"scope": "channel"}) and kept by tokens/refresh; absent means a conversation-level token |
Credential sources, in order of precedence: the Authorization: Bearer header
(Implemented (#26) on the native endpoint and the SSE stream, for non-browser clients), the
token query parameter (Implemented on the Phoenix binding, the native endpoint and the SSE
stream), hello.token (Implemented (#26), mekik/1 section 7). On the native endpoint the
credential is checked when hello arrives, not during the upgrade.
3.2 Identity resolution
- A verified
user_idclaim overrides any client-asserteduserId(anti-spoofing, mekik/1). Without one, the server echoes the asserteduserId(or mints one) inwelcome; such an id is informational and never used for authorization. - A
conversation_idclaim fixes the conversation; an asserted differentconversationIdis ignored. - With a channel-level token (no
conversation_id), an assertedconversationIdis adopted only when the conversation belongs to the token's channel and tenant and, if the token carriesuser_id, to that user. Otherwise the server creates a new conversation, asPOST /conversationsdoes. Whenwelcome.data.conversationIddiffers from the asserted one the client MUST reset its watermark to 0 (mekik/1 section 1). - Implemented (#26) on the native endpoint. "Belongs to that user" is checked against the
user_idrecorded in the conversation metadata when the native endpoint created it; conversations without one (created over REST) can be adopted by any token of the channel. The server replays from 0 when it replaced the asserted conversation.
3.3 Rejection, expiry and refresh
- Native endpoint, Implemented (#26): a missing, invalid or expired credential, or an inactive
channel, is answered with
errorunauthorizedand the socket is closed with code 4401 (mekik/1 section 7). A client SHOULD NOT reconnect with the same credential. - Phoenix binding, Implemented: the upgrade is refused (Phoenix answers HTTP 403); a join
that is not authorized for the conversation is answered
{"reason": "unauthorized"}. - Channel deactivation, Implemented: the sockets of a deactivated channel are disconnected
and cannot reconnect while it stays inactive. On the native endpoint this is close code
4403 after
errorchannel_inactive(Implemented (#26); the same close code is used for a forced disconnect). - Revocation, Implemented (#52): a revoked token (its family or its end user revoked, or a token refreshed longer
ago than the rotation grace period) is answered with
errortoken_revokedand close 4401; revoking a token disconnects the sessions of its subject with 4403 on every node. The client gets a new token from its backend. See Client tokens. - Permissions, Implemented (#52): a token with a
permsclaim may only do what it lists.helloneedsread(andwritewhen it would start a conversation): otherwiseerrorinsufficient_permissionand close 4403. In a session, atextframe withoutwriteor atypingframe withouttypingdrawserrorinsufficient_permission(details.required) and the session continues. Read receipts andsynccome withread. - Mid-session refresh, Implemented (#26) on the native endpoint: before
welcome.data.expiresAt, a client sendsauthwith a fresh token whose tenant, channel, conversation and user claims, and permissions (#52), match the session. The server answerstokenRefreshed. A token that does not match drawserrorforbiddenand the session continues on the old token. - Expiry, Implemented (#26) on the native endpoint: at
expiresAtwithout a refresh, the server sendserrortoken_expired(retryable) and closes with 4401. The client refreshes over REST and reconnects with the same watermark: nothing is lost because everything persistent is replayed.
4. Handshake
client -> hello (first frame; MAY be preceded only by nothing)
server -> welcome (identity, head watermark, open interrupts, capabilities, limits)
server -> genui_components (relayed from the bot channel, if it defines any)
server -> skills (relayed from the bot channel, if it defines any)
server -> replay (every persistent frame with seq > hello.watermark, in order)
server -> live frames
A client SHOULD send hello immediately after the upgrade. Any other frame before it draws
error no_session. A second hello is ignored (mekik/1 section 3.1). A field of the wrong type
in hello is ignored as if absent. Implemented (#26) on the native endpoint; the Phoenix binding is #22.
{ "type": "hello", "protocol": "converger/1", "conversationId": "c0a8012e-7d3b-4c5e-9f1a-2b3c4d5e6f70", "watermark": 12, "token": "eyJhbGciOiJIUzI1NiJ9.e30.sig" }
welcome is mekik/1's, with protocol: "converger/1" and compat: ["mekik/1"], plus Converger
fields. watermark is the conversation's head seq at handshake time: replayed frames carry
seq up to it, live frames are above it. pending re-announces the conversation's open interrupts as mekik/1
PendingViews ({id, data}, the interrupt frame without type and seq), oldest first: every stored
interrupt with no later interrupt_resolved of the same id, so a client whose watermark is already past an
interrupt (a reloaded page) renders it again. Implemented (#65); before, pending was always empty.
{
"type": "welcome",
"data": {
"protocol": "converger/1",
"compat": ["mekik/1"],
"conversationId": "c0a8012e-7d3b-4c5e-9f1a-2b3c4d5e6f70",
"userId": "user-42",
"connectionId": "conn-8f3a",
"watermark": 17,
"pending": [],
"scope": "conversation",
"expiresAt": 1750001800000,
"capabilities": { "acks": true, "receipts": true, "presence": true, "typing": true, "regenerate": false, "edit": false },
"limits": { "maxFrameBytes": 131072, "maxTextBytes": 65536, "maxMetadataBytes": 16384, "maxInFlight": 32, "heartbeatIntervalMs": 30000, "idleTimeoutMs": 60000, "replayBatch": 100, "replayMax": 10000 }
}
}
capabilities.regenerate and capabilities.edit are the flags chativa's mekik connector already
reads; they are true only when the conversation's bot channel advertises them.
capabilities.messageTypes MAY list the message types the server will deliver natively.
5. Frames
5.1 Persistent and transient frames
A persistent frame is stored as an activity, has a seq, and is replayed. A transient
frame is live-only: never stored, never replayed, no seq.
Persistent frames come in two shapes:
-
Message frames (
textand every message type of messages.md, plus the Converger typeseventandconversationUpdate) use the mekik/1textenvelope:field type notes typestring the message type (renderer name) idstring activity id (UUID minted by Converger, or the id a mekik bot supplied) seqinteger 1-based, per conversation from"user"or"bot"relative to the end user. Every other party (bot, human agent, system) is "bot", so chativa renders it on the correct sidedataobject the typed payload timestampinteger ms since the epoch (UTC); the server's commit time senderobject, optional Converger: {id, role, name?},roleone ofuser,bot,agent,systemmetadataobject, optional Converger activity metadata (REST channelData), at most 16 KiBactionsarray, optional quick-reply chips on a bot textframe (chativaparseChatFramerule)clientIdstring, optional the sender's clientId, so a sender's other connections can de-duplicate -
mekik/1-native frames (
tool_call,skill,genui,interrupt,interrupt_resolved) keep exactly the mekik/1 shape. Converger adds no top-level fields to them, so frames relayed from a mekik bot round-trip byte-identically (canonical JSON, mekik/1 section 9) except forseq, which is renumbered into the conversation's seq space. They are addressed by(conversationId, seq).
{ "type": "text", "id": "msg-1", "seq": 17, "from": "bot", "data": { "text": "Refund complete: ORD-42" }, "timestamp": 1750000000000 }
{ "type": "tool_call", "seq": 12, "data": { "id": "t1", "name": "get_order", "status": "completed", "result": { "id": "ORD-42", "total": 249.9 } } }
5.2 Server to client
type | persistent | schema | status |
|---|---|---|---|
welcome | no | frames/server/welcome | Implemented (#26) natively; Phoenix binding #22 |
text, message types | yes | frames/server/message | text: Implemented (#26) natively; rich types: Implemented (#68) |
tool_call, skill, genui, interrupt, interrupt_resolved | yes | frames/server/* | stored and replayed: Implemented (#68); sent by an agent on a channel-scoped connection: Implemented (#64) |
event | yes | frames/server/event | Implemented (#26) natively |
conversationUpdate | yes | frames/server/conversationUpdate | Implemented (#26) natively |
run, genui_components, skills | no | frames/server/* | relayed from the bot channel: Implemented (#64) on the native endpoint and the Phoenix binding |
error | no | frames/server/error | Implemented (#26) on the native endpoint; on the Phoenix binding Implemented (#24) for sends (frame event); the rest #22 |
ack | no | frames/server/ack | Implemented (#26) on the native endpoint and the Phoenix binding (#24) |
deliveryStatus | no | frames/server/deliveryStatus | Implemented (#25) on the Phoenix channel binding, the native endpoint and the SSE stream (#26); v1 framing on the Phoenix binding with #22 |
typing | no | frames/server/typing | Implemented (#25) on the Phoenix channel binding, the native endpoint and the SSE stream (#26); v1 framing on the Phoenix binding with #22 |
presence | no | frames/server/presence | Implemented (#25) on the Phoenix channel binding, the native endpoint and the SSE stream (#26); v1 framing on the Phoenix binding with #22 |
heartbeat | no | frames/server/heartbeat | Implemented (#26) on the native endpoint and SSE; Phoenix binding #22 |
tokenRefreshed | no | frames/server/tokenRefreshed | Implemented (#26) on the native endpoint; Phoenix binding #22 |
replayTruncated | no | frames/server/replayTruncated | Implemented (#26) on the native endpoint and SSE; Phoenix binding #22 |
5.3 Client to server
type | schema | handled by | status |
|---|---|---|---|
hello | frames/client/hello | server | Implemented (#26) on the native endpoint; Phoenix binding #22 |
text and message types | frames/client/message | persisted, then routed | text and rich types: Implemented on the native endpoint (#26, rich types #68); Implemented (#24) on the Phoenix binding as the event frame (section 2.2), next to REST POST .../activities and the pre-v1 postActivity event. On a websocket channel both bindings receive sends through Converger.Inbound |
resume, genui_event, client_tools, client_skills, abort, survey, regenerate, edit | frames/client/* | forwarded to the conversation's bot channel | Implemented (#64) on the native endpoint and the Phoenix binding (frame event); bot_unavailable when the conversation has no bot channel |
typing | frames/client/typing | relayed, not stored | Implemented (#25) on the Phoenix channel binding and the native endpoint (#26); v1 framing on the Phoenix binding with #22 |
read | frames/client/read | read receipt | Implemented (#25) on the Phoenix channel binding and the native endpoint (#26); v1 framing on the Phoenix binding with #22 |
sync | frames/client/sync | replay on a live connection | Implemented (#26) on the native endpoint; Phoenix binding #22 |
ping | frames/client/ping | answered with heartbeat | Implemented (#26) on the native endpoint; Phoenix binding #22 |
auth | frames/client/auth | token refresh | Implemented (#26) on the native endpoint; Phoenix binding #22 |
The send frame drafted in #21 is not part of v1: a user turn is the typed message frame of
mekik/1 ({"type": "text", "data": {"text": "..."}}), generalised to every message type, with an
optional clientId. The resume {watermark} frame drafted in #21 is named sync, because
resume is mekik/1's interrupt answer.
{ "type": "text", "clientId": "c-17", "data": { "text": "Where is my order?" }, "meta": { "locale": "tr-TR" } }
Client turn fields: data (typed by message type), clientId (section 7), meta (mekik/1
per-turn context, relayed to the bot channel, never stored), metadata (stored with the
activity), replyTo (the activity a tapped button answers). id and timestamp, which
chativa's OutgoingMessage carries, are accepted and ignored.
5.4 Activities as frames
Converger activities (Converger.Activities.Activity, canonical map in
Converger.Activities.Serializer) map to v1 frames as follows. Implemented (#26) for the native endpoint and the SSE stream, and (#65) for the REST history with format=frames (client API); typed message
payloads Implemented (#68). The activity types themselves are Implemented (#28,
activities).
activity type | frame | data |
|---|---|---|
message | text | {text, attachments?}: text is "" when null; non-empty attachments are carried verbatim |
message with a message_type (#68) | that type | the stored typed payload with the server envelope; a mekik-native type (tool_call, genui, ...) gets only seq added (messages.md section 4) |
messageReaction, messageUpdate, messageDelete | event named after the activity type (until #68 specifies own frames) | {name, text, value: {replyToId, text?, attachments?}}: text is the plain-text fallback ("agent-1 reacted with 👍"), value.replyToId the referenced activity. Clients apply updates and deletes to it |
message with reply_to_id | text | as message, plus frame-level replyTo: {id}; a client text frame with replyTo.id is stored with that reply_to_id |
event | event | {name?, text, value?} (for example a WhatsApp reaction to a message Converger does not know) |
conversationUpdate | conversationUpdate | the activity metadata, for example {event: "conversation_closed", status, reason} |
endOfConversation | conversationUpdate | {event: "conversation_ended", status: "closed"} |
typing | typing with seq | isTyping: true live, false on replay. The client typing frame is transient (#25); frames with a seq exist only for typing activities persisted through REST |
activity.sender becomes sender.id; from and sender.role derive from the side that
created the activity (end-user channel: user; bot channel: bot; agent console: agent;
lifecycle events: system). inserted_at becomes timestamp in milliseconds.
5.5 Unknown frames and reserved names
- A receiver MUST ignore unknown fields and unknown frame types (mekik/1). A server answers an
unknown or malformed client frame with
errorbad_requestand keeps the connection open. - A frame type that is not a protocol frame is a rich message frame (mekik/1 section 4.5): the
server persists it as a
messageactivity withmessage_type = frame.typeand relays it without interpretingdata(section 4 of messages.md). - Reserved frame types, which no message may use (
textexcepted): the mekik/1 set (hello welcome text resume genui_event client_tools client_skills abort tool_call skill skills genui genui_components interrupt interrupt_resolved run error typing), chativa's client frames (survey regenerate edit) and Converger's (ack deliveryStatus presence conversationUpdate endOfConversation event heartbeat ping read sync auth tokenRefreshed replayTruncated subscribe unsubscribe subscribed unsubscribed message activity activitySet frame). The list is$defs/reservedFrameTypeincommon.schema.json.
6. Ordering, replay and the watermark
6.1 seq
seq is allocated by the server when the activity is committed, under the conversation row
lock, so it is strictly monotonic and gap-free per conversation (Implemented:
Converger.Activities, per-conversation seq). Persistent frames are delivered on each
subscription in seq order.
6.2 Replay
After welcome the server replays every persistent frame with seq > hello.watermark, in order,
then switches to live delivery. Frames committed while the replay tail is read are held back and
de-duplicated, so each frame arrives exactly once and in order (mekik/1 section 2). Implemented (#26) on the native endpoint and the SSE stream, and (#22) on the Phoenix binding's converger:conversation:* topics with the legacy activitySet shape.
- The replay is read in batches of
limits.replayBatch(ws_replay_limit, default 100, Implemented as the cap of the currentactivitySetreplay). - At most
limits.replayMaxframes (default 10 000) are replayed per handshake orsync. Beyond that the server sendsreplayTruncated {watermark, headSeq}and continues live; the client fetches the rest withGET /api/v1/converger/conversations/ID/activities?watermark=N(paged byhas_more, Implemented) or sendssyncagain. - An absent
hello.watermarkmeans 0: a fresh client gets the whole transcript (bounded as above), as in mekik/1. The legacy Phoenix binding keeps its current behaviour (no watermark, no replay). - If the database is unavailable when the replay runs, the session still starts: the server sends
welcome, thenerrorreplay_unavailablewithdetails.watermark(the position it could not replay from) andretryAfterMs, and continues live after the head. The client keeps that watermark as its resume point and sendssyncfrom it afterretryAfterMs; the frames of thatsyncare older than live frames it may already hold, so it places frames byseq. Implemented (#35); on the Phoenix binding the eventerrorcarries{code: "replay_unavailable", watermark, retry_after_ms}.
{ "type": "replayTruncated", "data": { "watermark": 10000, "headSeq": 12500 } }
{ "type": "error", "data": { "code": "replay_unavailable", "number": 9002, "retryable": true, "message": "the database is unavailable, sync from the watermark later", "details": { "watermark": 15 }, "retryAfterMs": 1400 } }
6.3 Gap detection
Phoenix PubSub is at-most-once across nodes, so a live frame can be lost between nodes.
- Server, Implemented (#26) on the native endpoint and the SSE stream, and (#22) on the Phoenix binding's conversation topics (legacy shape): the connection process tracks the last
seqit pushed. A live frame withseqgreater thanlast + 1makes it read the missing range from the database and push it first. Frames withseqat or belowlastare dropped as duplicates. - Client, SHOULD: a client that stamps every send with a
clientIdknows everyseqit should see (frames plus its own acks). On a gap it sendssync {watermark: LAST_CONTIGUOUS}; the server replaysseq > watermarkwith the same hold-back rule. A client that sends withoutclientId(every mekik/1 client) cannot tell its own unechoed turns from a gap and simply tracks the highestseqseen, as mekik/1 does.
{ "type": "sync", "watermark": 15 }
6.4 Echo rule
A user's own turn is not delivered back to the connection that sent it (mekik/1 section 1); it is
delivered to the conversation's other connections, including the sender's other tabs, and replayed
on reconnect. With a clientId the sending connection gets an ack carrying the id and seq.
6.5 The watermark and its migration
In v1 a watermark is the integer seq (JSON number). This supersedes the opaque watermark of the Converger API (#63).
| phase | server emits | server accepts |
|---|---|---|
| Before v3.0 | base64url of seq:N (Converger.ConvergerAPI.Watermark) | seq:N base64url; legacy base64url activity UUID; anything else: REST starts from the beginning, WebSocket does not replay |
| v3.0 (Implemented (#63), ADR-0068) | integer in every v1 frame, in REST activitySet.watermark and in the tenant API's meta.watermark; the legacy Phoenix activitySet (and its postActivity reply and replay_unavailable error) keeps the string | integer (number or decimal string), seq:N base64url, legacy activity UUID |
| v3.0 + one minor release (planned) | integer everywhere | integer only; a base64url watermark draws invalid_watermark |
The forms cannot be confused: a decimal string is all digits, while a base64url watermark always
starts with a letter (c2Vx... for seq:, M, Y or Z for a UUID). Which release drops the
legacy forms is not decided yet; it is announced in the changelog one release ahead.
During the v3.0 window:
- Every input parses watermarks the same way (
ConvergerWeb.Protocol.parse_watermark/2): REST?watermark=on both APIs,GET .../conversations/:id?watermark=, SSE?watermark=andLast-Event-ID,hello.watermark,hello.watermarks,sync,read, and the Phoenix binding's join payload,ackandread. - An opaque watermark sent to a surface that emits integers (both REST APIs, v1 frames over the
native WebSocket and gRPC, SSE) is accepted and reported as the deprecated surface
opaque_watermark: a warning in the log and the telemetry event[:converger, :deprecated, :use]withsurface: :opaque_watermarkandapi(converger,tenant,protocolorsse). NoDeprecationheader is added, since the endpoint itself stays. The Phoenix binding does not report it, since it still emits the opaque form. - On an empty REST page the server returns the position the client sent as an integer, whatever
form it came in (a legacy activity-id watermark becomes that activity's seq), or
nullwithout one. - Clients that treat the watermark as a token and pass it back unchanged need no change, except that a typed client must accept a JSON number where it used to expect a string.
Invalid watermarks (Implemented (#26) on the native endpoint): in hello a watermark of the wrong type is ignored as if
absent (mekik/1). A well-formed watermark above the head is answered with error
invalid_watermark and no replay; welcome.data.watermark tells the client the real head, and
the client SHOULD then sync from its last trusted value or 0.
7. Sending, acks and idempotency: Implemented (native endpoint #26, Phoenix binding #24)
- A send MAY carry
clientId(1 to 128 characters ofA-Z a-z 0-9 . _ : ~ -, unique per conversation and sender, for example a UUID or a counter prefixed with a session id). - The server persists the activity, then sends
ack {clientId, id, seq, timestamp}to the sending connection only. The ack is sent after the commit and before the frame is fanned out. clientIdis stored as the activity's idempotency key, namespaced by sender asws:<sender>:<clientId>, under the unique index on(conversation_id, idempotency_key)that the RESTX-Idempotency-Keyheader already uses (Implemented for REST, the native endpoint and the Phoenix binding'spostActivity). The namespace keeps one sender's ids apart from another's and from REST keys; both WebSocket bindings share it, so a resend over the other binding is recognised. A retransmittedclientIdis not stored again: the server answers with the original ack plusduplicate: true. The dedupe window is therefore the lifetime of the activity (at least 24 hours is the guarantee clients may rely on).- A rejected send is answered with
errorcarrying the sameclientIdand is not persisted. Retryable errors (rate_limited,too_many_in_flight,unavailable) MAY be retried with the sameclientId; non-retryable ones MUST NOT be. - At most
limits.maxInFlight(32) sends per connection may be unacked; more drawtoo_many_in_flight. A client that reconnects re-sends its unacked sends with their originalclientIds afterwelcome. - A send without
clientIdbut with anid(mekik/1 and chativa clients stamp every outgoing message with one) is treated as ifclientIdwere thatid, provided it matches theclientIdsyntax above: unmodified mekik/1 clients therefore also get exactly-once persistence and anack(which they ignore by the unknown-frame rule). If both are present,clientIdwins. - A send with neither (or with an
idthat does not match the syntax) gets no ack and is persisted once per frame received. - Native endpoint details (Implemented (#26)): the activity's sender is the session's
userId. Sends count against the tenant'sactivity_createrate limit bucket, shared with REST; exceeding it drawsrate_limitedwithretryAfterMs. Validation errors drawinvalid_messagewithdetailskeyed by field, the same map REST returns with HTTP 422. - Phoenix binding details (Implemented (#24), event
frame): the same rules as the native endpoint (sender, rate limit, validation errors, key). The sender is the token'suser_id, or"user"without one. Atextframe and apostActivitywith the sameclientIdare the same send;postActivityrepliesduplicate: truefor a resend too. A connection's sends are persisted one at a time, in arrival order, and eachackis pushed before the sender's ownactivitySetof that activity. When more thanmaxInFlightsends arrive at once, the oldestmaxInFlightare accepted and the newest are refused withtoo_many_in_flight, so the accepted ones keep their order (ADR-0029).
{ "type": "ack", "clientId": "c-17", "id": "a2f7d9e4-3b1c-4d5e-8f9a-0b1c2d3e4f50", "seq": 18, "timestamp": 1750000004000 }
Sends into a closed conversation are refused with conversation_closed (Implemented on
postActivity, the legacy socket and REST, HTTP 409); the conversation must be reopened first.
8. Receipts, typing, presence, heartbeat
8.1 deliveryStatus: Implemented (#25)
Receipts, typing and presence are implemented on the Phoenix channel binding
(converger:conversation:<id> on /socket/converger): each frame of this section is pushed as the
payload of the Phoenix event named by its type, and typing and read are client pushes of that
name. The raw v1 framing follows with #22. The client contract of the binding is in
WebSocket.
The native endpoint (section 2.1, #26) sends and accepts the same frames as raw v1 frames, and
the SSE stream (section 2.4) delivers them too; an SSE stream counts as a connection for presence
but cannot send typing or read. On the native endpoint a read that is accepted produces no
frame for the reader; an invalid one draws error invalid_watermark, and a typing without a
boolean isTyping draws bad_request. All transports share one implementation
(ConvergerWeb.ConversationSignals).
The participant of a connection is derived from its conversation token: role: "user" and id
the token's user_id, or "anonymous" without one. Channel-scoped sockets (section 10, #64/#67)
will add the agent role. A connection never receives the typing, read receipts or presence of
its own participant.
Converger tracks a delivery per activity and target channel (Converger.Deliveries). Every status
change is pushed as deliveryStatus to every connection of the conversation except end-user
connections that did not send the activity (an end user is identified by the token's user_id;
anonymous end users cannot be told apart and receive every status). Statuses only move forward:
queued, sent, delivered, read; failed is terminal for that attempt series and carries
attempt and error (code delivery_failed, the last error as message). timestamp is the
provider's time of the receipt when it sent one.
{ "type": "deliveryStatus", "data": { "activityId": "a2f7d9e4-3b1c-4d5e-8f9a-0b1c2d3e4f50", "seq": 18, "channelId": "wa-main", "status": "delivered", "timestamp": 1750000005000 } }
A client marks everything up to a seq as read with read {watermark}. The server stores the
participant's read watermark per conversation (it never moves backwards and is capped at the
conversation's head seq), forwards it to channels that support read receipts (adapter callback
send_read_receipt/2; WhatsApp Cloud API: the participant's latest message up to that seq is
marked read), and sends deliveryStatus {upToSeq, status: "read", by} to the other participants.
A watermark that does not move the stored position produces no frame. On the Phoenix binding the
watermark may also be the opaque activitySet watermark (section 6.5), since its activity frames
carry no seq.
{ "type": "read", "watermark": 18 }
8.2 typing: Implemented (#25)
typing {isTyping} is transient in both directions and flat, as chativa's parseChatFrame
expects. The server relays it to the conversation's other connections and to target channels that
support it (adapter callback send_typing/2; WhatsApp Cloud API typing indicator, refreshed at
most every 20 s per connection), adding from and sender. A client sends at most one per 2 s;
repeats of the same state inside 2 s are dropped. An indicator that is not refreshed within 6 s
expires, and a connection that closes while typing emits isTyping: false. The client typing
frame is never stored; typing activities posted over REST still are, for existing clients.
{ "type": "typing", "isTyping": true, "from": "bot", "sender": { "id": "agent-7", "role": "agent" } }
8.3 presence: Implemented (#25)
presence {participant, status, connections?} is derived from the participant's live connections
across nodes (ConvergerWeb.ConversationPresence, a Phoenix Presence per conversation keyed by
participant). A connection receives one online frame per other participant already connected
when it joins, then a frame whenever a participant's connection count changes (offline with
lastSeenAt when it drops to 0). It is configured per channel with the config key presence:
"identified" (default: every participant except anonymous end users), "all" (anonymous end
users too, as one participant anonymous) or "off". away is not emitted.
8.4 conversationUpdate
A persistent frame for lifecycle and membership changes. Implemented as an activity: closing,
expiring and reopening a conversation emits a conversationUpdate activity with sender system.
Delivered as a v1 frame with #22; participant changes are #25.
{ "type": "conversationUpdate", "id": "0b9a6c3e-2d4f-4e5a-8b7c-9d0e1f2a3b4c", "seq": 30, "from": "bot", "sender": { "id": "system", "role": "system" }, "data": { "event": "conversation_closed", "status": "closed", "reason": "expired" }, "timestamp": 1750000009000 }
8.5 heartbeat and idle timeout: Implemented (#26) on the native endpoint
The server sends heartbeat after heartbeatIntervalMs (30 s) of outbound silence and in answer
to ping. A connection in the mekik/1 profile (section 14.1) gets a WebSocket ping instead, whose pong counts as
inbound activity. A client sends ping after idleTimeoutMs / 2 of its own silence. A connection with no
inbound frame for idleTimeoutMs (60 s) is closed with 4408. WebSocket ping/pong control frames
also count as activity.
{ "type": "heartbeat", "timestamp": 1750000031000, "nonce": "p-3", "headSeq": 18 }
9. The bot channel and mekik relay: Implemented (#64)
A conversation's bot channels are the channels that enabled routing rules target from its origin
channel and that speak mekik/1 to a bot: a websocket channel with at least one channel-scoped
connection (section 10: an agent, the @converger/agent SDK, the @mekik/converger transport), or a
channel whose adapter relays to a bot itself (capability :bot_relay: the mekik adapter, Mode A of #64,
Implemented (#64), see the mekik channel and
ADR-0066). For that
conversation (ConvergerWeb.Protocol.BotRelay, ADR-0063):
- client
resume,genui_event,client_tools,client_skills,abort,survey,regenerateandeditare forwarded to the bot channel unchanged. A user turn'smetais relayed ahead of the turn and arrives on the bot's copy of that turn; it is never stored; - the bot's transient frames (
run,genui_components,skills,error) are relayed to every connection of the conversation (a bot'stypingis an ordinary typing signal), and its persistent frames are stored, renumbered into the conversation's seq space and fanned out; - the turn lock is the bot's: Converger does not serialise turns. A bot's
errorbusy,interrupted,not_interruptedorincomplete_resumeis relayed to the conversation's connections (the bot does not say which frame caused it, so every connection sees it); - a user turn whose
replyTonames an openinterrupt(a tapped WhatsApp button, a chativa chip) is stored as usual and reaches the bot asresume {answers: {INTERRUPT_ID: value}},valuebeingmetadata.action.value, else the label, else the text (messages.md, section 6).
If the conversation has no bot channel, the bot-only frames draw error bot_unavailable.
The mekik channel adapter is a bot channel that dials its mekik/1 server itself, one upstream
WebSocket per conversation: the end user's turns go upstream as mekik/1 text (an interrupt answer as
resume), the forwarded bot-only frames as they are, and the server's persistent frames are stored
as the channel's bot (its text echoes of user turns skipped) while its transient frames are relayed
as above. Converger keeps mekik's conversation id and its highest seq per conversation to resume
with hello.watermark; mekik's seq never appears in Converger's frames (two transcripts, two seq
spaces, see the mekik channel).
10. Channel-scoped sockets: Implemented (#64) on the native endpoint
On the native endpoint (/socket/converger/v1) a channel-scoped token opens the connection below
(ConvergerWeb.Protocol.ChannelSession, ADR-0063).
On the Phoenix binding a scope: "channel" token of a websocket channel joins
converger:channel:CHANNEL_ID and receives every activity delivered to the channel as legacy
activitySet frames carrying conversation_id; postActivity and ack name their
conversation_id (WebSocket channel type), Implemented (#22).
A token with scope: "channel" opens a connection that sees every conversation of its channel:
agent consoles, the @converger/agent SDK (#67) and the mekik transport (#64).
welcomehasscope: "channel"andchannelId, and noconversationId,userId,watermarkorpending.- Connection-level frames (
hello,welcome,auth,tokenRefreshed,ping,heartbeat, anderrorabout the connection) are sent bare. - Every conversation-level frame, in both directions, is wrapped as
{conversationId, frame}. The envelope has notype; that is how a receiver tells it apart from a bare frame. - Live frames of every conversation of the channel are delivered. To resume, a client lists
per-conversation watermarks in
hello.watermarks, or sends an envelopedsynclater. Conversations not listed start live; the client discovers missed conversations over REST. - Acks, errors and receipts about one conversation are enveloped with its
conversationId. - A connection follows at most
limits.maxConversationsconversations at once (default 5000); beyond that the server answerstoo_many_subscriptionsand stops delivering live frames for the least recently active conversations. - The token's channel must be a
websocketchannel (elseforbidden, close 4403). A conversation is followed when it is listed inhello.watermarks, when something is delivered for it to the channel, or when the connection sends a frame for it. It must be owned (of this channel) or routed (an enabled routing rule targets this channel from the conversation's channel); any other drawsconversation_not_foundin its envelope. An owned conversation receives every activity; a routed one what the pipeline delivers to the channel (after its middleware), while replays and gap fills read the transcript through the same middleware. - From the agent's side
fromisuserfor the end user's activities (those that came in on the conversation's origin channel) andbotfor its own and every other bot's or agent's. A user turn carriesmeta.channel {id, type}(the channel it came in on) andmeta.participant {id}, merged with the turn.s ownmeta. - The agent sends message frames (stored as its replies, keeping a message
id; aclientIdor a mekik messageidmakes the send idempotent within the conversation and draws anack; on a mekik-native frame theidnames the interrupt and only an explicitclientIdcounts), mekik-native persistent frames (tool_call,skill,genui,interrupt,interrupt_resolved; aseqit sends is ignored), transientrun,genui_components,skills,error(relayed, section 9), andtyping,read,sync(frames/channel/agent-frame.schema.json). On a channel withrequire_ack,readalso acknowledges the channel's deliveries. End-user-only frames (resume, ...) drawbad_request. The sender is the token'suser_id, elsehello.userId, elseagent. - Agent groups (Converger addition):
hello.groupjoins a named group of the channel. The connections of a group share the channel's conversations, so several agent processes behind one channel never both answer a turn (ADR-0063, ADR-0069). A connection without a group sees every conversation.- Eligibility. A member is eligible for the user turns committed at least
group_settle_ms(server setting, default 1000 ms) after it joined. The group spans every node of a cluster. - Affinity. Each user turn is delivered to exactly one member: among the members eligible for it, the rendezvous-hash winner for its conversation. While the membership is stable every turn of a conversation goes to the same member, and conversations spread evenly over the members. When no member is eligible yet (a group that just started), every member counts.
- Join. A new member takes over about 1/n of the conversations, and only for turns committed after it became eligible: a turn committed earlier stays with its previous owner, which may still be answering it, and is never delivered to the new member. The previous owner's late frames for a conversation that moved are accepted as usual.
- Leave. When a member disconnects or dies, its conversations move to the remaining members. The new owner of
each receives the conversation's unanswered user turns since the first turn it left to the departed member
(the trailing
from: userframes, after the last frame of any bot or agent), including turns committed after the member died but before the cluster noticed, then every later turn. A turn the departed member answered is not delivered again. Taken-over turns carrymeta.channelandmeta.participantbut no relayed mekikmeta. - Busy. Converger does not serialise turns: a member receives a conversation's turns as they come, and the turn
lock is the bot's (section 9). A bot that is still answering sends
error {code: "busy"}, which reaches the end user's connections. - Membership changes move only the conversations of the member that came or went. Ownership is per connection: an agent that reconnects joins as a new member.
- Eligibility. A member is eligible for the user turns committed at least
{ "conversationId": "c0a8012e-7d3b-4c5e-9f1a-2b3c4d5e6f70", "frame": { "type": "text", "id": "msg-9", "seq": 19, "from": "user", "data": { "text": "Where is my order?" }, "timestamp": 1750000004000 } }
{ "conversationId": "c0a8012e-7d3b-4c5e-9f1a-2b3c4d5e6f70", "frame": { "type": "text", "clientId": "agent-c-3", "data": { "text": "Let me check that for you." } } }
11. Limits
Values are defaults; the server announces the effective ones in welcome.data.limits.
| limit | default | today | on violation |
|---|---|---|---|
maxFrameBytes, one inbound frame | 128 KiB | Implemented on the Phoenix sockets and the native endpoint (WS_MAX_FRAME_BYTES, #27) | payload_too_large; above 1 MiB the socket is closed with 1009 |
| inbound frames per connection | 20 per second | Implemented on the Phoenix sockets and the native endpoint (WS_MAX_MESSAGES_PER_WINDOW, #27) | rate_limited with retryAfterMs |
| joined channels per Phoenix socket | 50 | Implemented (WS_MAX_JOINS, #27) | too_many_joins reply |
| outbound frames buffered for a slow client | 1 000 | Implemented on the Phoenix sockets and the native endpoint (WS_SLOW_CONSUMER_QUEUE_LEN, #27) | close 4503 slow_consumer |
maxTextBytes, data.text | 64 KiB | Implemented (activity_limits.max_text_bytes) | payload_too_large |
maxMetadataBytes, metadata as JSON | 16 KiB | Implemented (max_metadata_bytes) | payload_too_large |
typed message payload as JSON | 128 KiB | Implemented (#68) (max_payload_bytes) | invalid_message |
attachments (legacy text.data.attachments) | 10 items, 4 KiB each | Implemented | invalid_message |
extensions in message data | 8 KiB | Planned (#68) | payload_too_large |
maxInFlight, unacked sends per connection | 32 | Implemented on the Phoenix binding (#24, WS_MAX_IN_FLIGHT); the native endpoint processes sends one at a time, so at most one is ever unacked | too_many_in_flight |
heartbeatIntervalMs | 30 000 | Implemented (#26) natively and on SSE (heartbeat_interval_ms); Phoenix heartbeat on the binding | - |
idleTimeoutMs | 60 000 | Implemented (#26) natively (idle_timeout_ms); Phoenix default on the binding | close 4408 |
replayBatch | 100 | Implemented (ws_replay_limit, PAGINATION_WS_REPLAY_LIMIT) | - |
replayMax per handshake or sync | 10 000 | Implemented (#26) | replayTruncated |
maxConversations per channel-scoped socket | 5 000 | Implemented (#64) (config :converger, ConvergerWeb.Protocol, max_conversations:) | too_many_subscriptions |
| agent group settle delay (section 10) | 1 000 ms | Implemented (#67) (config :converger, ConvergerWeb.Protocol, group_settle_ms:) | - |
| sends per tenant | activity_create bucket | Implemented for REST (429 + Retry-After) and native endpoint sends (#26); Phoenix socket sends are limited per connection (above) | rate_limited with retryAfterMs |
| typing frames per connection | 1 per 2 s | Implemented (#25): repeats of the same state within 2 s | dropped silently |
12. Errors
12.1 The error frame
mekik/1 error {data: {code, message}}, sent only to the connection that caused it, plus Converger
fields: number (stable numeric code), retryable, retryAfterMs, clientId and frameType
(what the error answers) and details (for example validation errors per field, as the REST API
returns them). Codes relayed from a bot channel are passed through unchanged.
{ "type": "error", "data": { "code": "rate_limited", "message": "too many messages", "number": 3000, "retryable": true, "retryAfterMs": 1500, "clientId": "c-17", "frameType": "text" } }
12.2 Error codes
The string code is the stable key; number is stable too and groups codes by range. REST
equivalents are given for codes the API shares.
| code | number | retryable | closes | REST | meaning |
|---|---|---|---|---|---|
bad_request | 1000 | no | no | 400 | malformed JSON, unknown type, missing or mistyped field (mekik/1) |
no_session | 1001 | no | no | - | frame before hello, or after disconnect (mekik/1) |
unsupported_protocol | 1002 | no | 4400 | - | hello.protocol names a version the server does not speak |
invalid_watermark | 1003 | no | no | - | watermark above the head, or a legacy form after the migration window |
invalid_message | 1004 | no | no | 422 | the message failed validation (details per field) |
unauthorized | 2000 | no | 4401 | 401 | missing or invalid credential (mekik/1) |
token_expired | 2001 | yes, after refresh | 4401 | 401 | the session's token expired |
forbidden | 2002 | no | no | 403 | the token's scope does not allow the frame or conversation |
channel_inactive | 2003 | no | 4403 | 400 | the channel was deactivated |
insufficient_permission | 2004 | no | 4403 on hello | 403 | the token's perms claim lacks the permission the frame needs; details.required (Implemented, #52) |
token_revoked | 2005 | no | 4401 | 401 | the token was revoked or refreshed longer ago than the grace period; get a new one (Implemented, #52) |
rate_limited | 3000 | yes | no | 429 | rate limit hit; wait retryAfterMs (REST: Retry-After) |
payload_too_large | 3001 | no | no (1009 above the hard cap) | 413 | a size limit was exceeded |
too_many_in_flight | 3002 | yes | no | - | more than maxInFlight unacked sends |
too_many_subscriptions | 3003 | no | no | - | channel-scoped socket follows too many conversations |
slow_consumer | 3004 | yes | 4503 | - | the client reads too slowly; reconnect after retryAfterMs and resume from the watermark |
conversation_closed | 4000 | no | no | 409 | the conversation is closed; reopen it first |
conversation_not_found | 4001 | no | no | 404 | unknown conversation, or not visible to the token |
busy | 5000 | yes | no | - | relayed from the bot: a run is in flight (mekik/1) |
interrupted | 5001 | no | no | - | relayed: answer the open interrupt first (mekik/1) |
not_interrupted | 5002 | no | no | - | relayed: resume with no open interrupt (mekik/1) |
incomplete_resume | 5003 | no | no | - | relayed: resume missed an open interrupt (mekik/1) |
bot_unavailable | 5004 | yes | no | - | the frame needs a bot channel and there is none, or it is unreachable |
internal | 9000 | yes | no | 500 | unexpected server error (mekik/1 TS transport) |
unavailable | 9001 | yes | 1012, 1013 | 503 | the node is draining; reconnect, possibly elsewhere (Implemented, #27); or the database is unavailable: resend after retryAfterMs (Implemented, #35). Before welcome the connection then closes with 1013 |
replay_unavailable | 9002 | yes | no | - | the replay could not read the database; sync from details.watermark after retryAfterMs (Implemented, #35) |
Retryable means the same frame (with the same clientId) may be sent again later.
12.3 Close codes
| code | when |
|---|---|
| 1000 | normal close |
| 1009 | inbound frame above the hard size cap |
| 1011 | internal error |
| 1012 | server restart or node draining: reconnect with backoff and the same watermark |
| 1013 | try again later: the database was unavailable during the handshake (after error unavailable, #35) |
| 4400 | unsupported protocol |
| 4401 | unauthorized, token_expired or token_revoked (mekik/1 AUTH_CLOSE_CODE) |
| 4403 | channel_inactive, insufficient_permission on hello, or the socket was force-disconnected (ConvergerWeb.Sockets, also when its token is revoked) |
| 4408 | idle timeout |
| 4503 | slow_consumer: too many frames buffered for the client |
| 1008 | native endpoint: the conversation named by the token does not exist or belongs to another channel (after error conversation_not_found) |
1012 and 4503 carry a JSON close reason, {"reason": "unavailable" | "slow_consumer", "retryAfterMs": N}, with a
jittered N (Implemented on the Phoenix sockets and the native endpoint, #27; see
WebSocket limits and draining).
Clients SHOULD reconnect with jittered exponential backoff on every code except 4400, and on 4401
only after obtaining a new token. A retryAfterMs from the close reason or from the unavailable
error before a 1013 close is the minimum delay. The backoff SHOULD be capped at about 10 s, so a
client that failed a few times while a node restarted reconnects soon after it is back. Under a
reconnect burst the server queues handshakes before it answers 1013
(ADR-0077).
13. Compatibility and deprecation
13.1 Within v1
Only additive changes: new optional fields, new frame types, new message types, new error codes, new capabilities. Receivers ignore what they do not know, so these never break a peer. Every addition updates this document and the schemas in the same change.
13.2 A future v2
A breaking change creates converger/2, negotiated by the subprotocol converger.v2 (and
hello.protocol). A server supports v1 and v2 side by side for at least 12 months after v2 ships;
welcome.data.compat lists what the server still speaks.
13.3 Deprecation of the pre-v1 surfaces
| surface | status | removal |
|---|---|---|
/socket conversation:* topic (ConvergerWeb.UserSocket, new_activity, last_activity_id) | deprecated (#23) | no earlier than two minor releases and 6 months after #23 |
legacy tokens: POST /api/v1/tokens, channel tokens in x-channel-token (incl. POST /api/v1/conversations) | deprecated (#23) | same window as above |
activitySet {activities, watermark, has_more} and postActivity on converger:conversation:* (join without protocol) | deprecated when #22 ships | same window as above |
| opaque base64url watermarks | accepted for one release from v3.0 (section 6.5); REST, the tenant API and v1 frames emit the integer (#63), only the legacy Phoenix activitySet still emits the string | the following minor release, announced one release ahead |
Deprecated surfaces log a warning per connection (per request for HTTP, which also carries the RFC 9745
Deprecation and Link: <...>; rel="deprecation" headers), emit the telemetry event
[:converger, :deprecated, :use], and are announced in the changelog when they are deprecated and again one release
before removal.
14. mekik/1 compatibility
Converger Protocol v1 is a superset profile of mekik/1. Each mekik/1 frame:
| mekik/1 frame | direction | Converger v1 | notes |
|---|---|---|---|
hello | c2s | extended | all mekik/1 fields unchanged; adds protocol, watermarks (channel scope); watermark also accepts legacy forms during migration |
text (user turn) | c2s | extended | adds optional clientId, metadata, replyTo; same frame family carries every message type |
resume | c2s | adopted | forwarded to the bot channel; replay-from-watermark is sync |
genui_event | c2s | adopted | forwarded |
client_tools | c2s | adopted | forwarded; Converger does not interpret declarations |
client_skills | c2s | adopted | forwarded |
abort | c2s | adopted | forwarded |
survey, regenerate, edit (chativa connector) | c2s | adopted | forwarded; gated by welcome.data.capabilities |
welcome | s2c | extended | protocol: "converger/1", compat: ["mekik/1"]; adds scope, channelId, expiresAt, capabilities, limits; watermark is the head seq |
text | s2c | extended | adds optional sender, metadata, clientId; from stays user or bot |
| rich message frames (section 4.5) | s2c | adopted | the open rule is unchanged; Converger adds typed schemas for the vocabulary in messages.md |
tool_call | s2c | adopted | byte-identical; seq renumbered into the conversation seq space |
skill | s2c | adopted | as tool_call |
genui | s2c | adopted | as tool_call |
interrupt | s2c | adopted | as tool_call; answers from non-mekik channels become resume; open ones are re-announced in welcome.pending |
interrupt_resolved | s2c | adopted | as tool_call |
genui_components | s2c | adopted | relayed after welcome |
skills | s2c | adopted | relayed after welcome |
run | s2c | adopted | relayed from the bot channel |
error | s2c | extended | adds number, retryable, retryAfterMs, clientId, frameType, details; adds hub codes (section 12.2) |
typing (chativa) | s2c | extended | also sent by clients (c2s); adds from and sender |
turn lock, busy, interrupted semantics (section 5) | - | not applicable | owned by the bot; Converger only relays the errors |
| backplane envelope (section 5.1) | - | not applicable | node-to-node inside mekik; Converger uses Phoenix PubSub |
| MCP and A2A doors (sections 13, 14) | - | not applicable | not WebSocket frames; a mekik bot keeps serving them itself |
| auth close 4401 (section 7) | - | adopted | also used for token_expired; mid-session refresh is an addition |
| query-string identity (section 1, section 7) | - | adopted | hints for L7 affinity; the token decides |
Additions without a mekik/1 counterpart: ack, deliveryStatus, presence,
conversationUpdate, event, heartbeat, tokenRefreshed, replayTruncated, client read,
sync, ping, auth, and the channel-scoped {conversationId, frame} envelope. A mekik/1
client never sends clientId, but the id it stamps on outgoing messages is used as one
(section 7), so its sends are de-duplicated. mekik/1's open rule renders an unknown frame type as a message, so a
mekik/1 client does not receive the transient additions at all (section 14.1).
The persistent frames of mekik's golden fixtures (conformance/fixtures/*.json) are vendored in
test/protocol/fixtures/mekik/ and validated against the Converger schemas by the conformance
suite. Their storage round trip (stored, re-read and rendered back byte-identically in canonical JSON,
except seq and, on message frames, the server's timestamp and sender) is Implemented (#68):
test/protocol/mekik_storage_round_trip_test.exs.
14.1 The mekik/1 profile: Implemented (#65)
mekik/1 receivers treat an unknown frame type as a rich message rendered by name (mekik/1 section 4.5), and
chativa's @chativa/connector-mekik does exactly that, so additive Converger frames would show up as empty chat
bubbles. On the native endpoint, a connection is a mekik/1 client when it negotiated no WebSocket subprotocol and
its hello has no protocol or protocol: "mekik/1". For such a connection the server:
- does not send
ack,deliveryStatus,presence,tokenRefreshedorreplayTruncated; - sends
heartbeatas a WebSocket ping (section 8.5); the pong keeps the connection insideidleTimeoutMs, which a mekik/1 client that never sendspingwould otherwise exceed; - reports
token_expiredandtoken_revokedwith the mekik/1 auth codeunauthorized, keeping theirnumber(2001, 2005) and close code 4401, so a mekik/1 auth provider refreshes its credential.
Every other frame, persistent ones included (event and conversationUpdate too), is unchanged. A client that
offers converger.v1 (or converger.v1+json, converger.v1+msgpack) or sends hello.protocol: "converger/1"
gets the full protocol. The other transports (Phoenix binding, SSE, gRPC) are not used by mekik/1 clients and have
no profile. Implementation: ConvergerWeb.Protocol.MekikProfile, applied by ConvergerWeb.ProtocolSocket
(ADR-0065). How to connect chativa:
connecting chat clients.
Verified live (#63, #65). The unmodified @chativa/connector-mekik has been run against a mekik bot behind
Converger, in both bot modes: a mekik channel (Mode A, the adapter dialing a mekik/1
server) and an agent on a channel-scoped socket (Mode B, section 10). A user's text reaches the bot. chativa
renders the bot's live run as typing, the tool_call running and completed traces, a genui chunk, the bot's
text and the interrupt as quick-reply chips. A tapped chip goes out as chativa's own resume frame and reaches
the bot, and the bot's interrupt_resolved and final text render. After a disconnect, chativa reconnects with its
watermark and renders exactly the frames it missed, once. After a page reload (resumeConversation, watermark past
the interrupt), welcome.pending brings the chips back and a tap still resumes the bot
(test/protocol/chativa_live_bot_test.exs).
15. Conformance
test/protocol/ (tag :protocol, part of the normal test run) checks that:
- every schema in
priv/protocol/v1builds and itsexamplesvalidate, directly and through the direction dispatchersserver-frame.schema.jsonandclient-frame.schema.json; - every frame of the example sessions in
priv/protocol/v1/examples/validates, and the frames inexamples/invalid.jsonare rejected; - every JSON block annotated with a schema in this document and in messages.md
validates (fenced as
json schema=PATH); - mekik/1's golden fixture frames are valid Converger server frames with contiguous
seq. - every persistent frame of those fixtures is stored as an activity and renders back identical in
canonical JSON (
mekik_storage_round_trip_test.exs, #68). - chativa, the reference client, works unmodified: its
MekikConnectorandparseChatFrame(vendored intest/protocol/fixtures/chativa/) run in Node against a real server and see the expected messages, quick replies, tool-call traces, GenUI chunks and typing, and no Converger-only frame (chativa_compat_test.exs, tag:chativa, #65). CI runs it in its own job with Node 24 and fails when Node is missing. - the same unmodified connector works end to end against a live mekik bot behind Converger, through the
mekikadapter (Mode A, a fake mekik/1 server) and through a channel-scoped agent socket (Mode B), covering typing viarun, tool calls, GenUI, text, interrupt chips answered withresume, a reconnect with the watermark that delivers exactly the missed frames once, andwelcome.pendingafter a page reload (chativa_live_bot_test.exs, tag:chativa, same CI job; section 14.1).
Implementation issues (#22 to #28, #64) extend the suite with live round trips against the server:
connect, handshake, replay, ack, dedupe and gap fill.
The native endpoint (#26) has these round trips over a real socket, with every server frame
validated against server-frame.schema.json: test/converger_web/protocol_socket_test.exs and,
for the SSE fallback, test/converger_web/controllers/event_stream_controller_test.exs.