Skip to main content

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 seq with no gaps (mekik/1 section 2). A client's watermark is the highest seq it 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):

idlifetimemeaning in Converger
userIdpermanentthe end user. Verified when the token carries user_id.
conversationIduntil deleteda Converger conversation (UUID).
connectionIdone socketa routing handle for one live connection.
watermarkper clientthe 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:

    subprotocolencodingWebSocket messages
    converger.v1JSONtext
    converger.v1+jsonJSON (explicit alias of converger.v1)text
    converger.v1+msgpackMessagePack: the same frame maps, re-encodedbinary (text JSON frames are also accepted)
  • A client that offers no subprotocol is accepted and spoken to as converger/1 in JSON with mekik/1 semantics (mekik/1 defines no subprotocol). This is how unmodified mekik/1 clients connect. Unless its hello names protocol: "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.v2 is offered next to converger.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; hello and the token decide.

  • Inbound frames above limits.maxFrameBytes draw payload_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_limited with retryAfterMs; a client that does not read fast enough loses typing and presence frames first and is then closed with 4503; while the node drains new connections get HTTP 503 with Retry-After, and open ones are closed with 1012 (section 12.3).

  • A runnable client using only Python's websockets library is in examples/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 protocol gets the pre-v1 frames: the join payload may carry a watermark (the opaque one of a previous activitySet, or the integer seq); the server pushes activitySet events {activities, watermark, has_more} (replay, then one per live activity). See section 13.3. The client sends with the event postActivity (an activity with an optional clientId; 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 and ack.
  • Implemented (#24), on any join: a client sends v1 text frames with the event frame (section 7). The ack or error comes back as a frame event; the Phoenix reply is ok, only a transport receipt. Other client frame types draw error bad_request (typing and read receipts keep their own events on this binding), and frame is refused on converger:channel:* topics ({"reason": "bad_request"}). On a join without protocol the sender also receives its own turn in an activitySet, after its ack.
  • Planned (#22): a join payload {"protocol": "converger/1", ...hello fields} switches the topic to v1. Every server frame is then pushed as the Phoenix event frame whose payload is the complete frame (including type); clients push frames with the event frame too. The Phoenix reply to a client push is only a transport receipt: the protocol answer is the ack or error frame. The Phoenix heartbeat (topic phoenix) replaces the heartbeat/ping frames 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:

  1. Server-Sent Events for receiving, REST for sending:

    GET https://HOST/api/v1/converger/conversations/CONVERSATION_ID/events?watermark=N
    Accept: text/event-stream

    Every event is a v1 server frame (the same JSON as on the native endpoint) with the frame type as the SSE event name. Persistent frames carry their seq as the SSE id, so a reconnecting EventSource sends Last-Event-ID and resumes exactly after it; Last-Event-ID takes precedence over ?watermark=. Replay, de-duplication, gap filling, replayMax and replayTruncated are as in section 6; receipts, typing and presence (section 8) are streamed as well. The server sends heartbeat every heartbeatIntervalMs, and ends the stream with error token_expired at token expiry, channel_inactive when the channel is deactivated, or unavailable (with retryAfterMs) when the node drains; while it drains, new streams get HTTP 503 with Retry-After. The credential is the Authorization: Bearer header or, because EventSource cannot set headers, ?token=. Turns are sent with POST /api/v1/converger/conversations/ID/activities (X-Idempotency-Key gives the same exactly-once persistence as a clientId). No welcome is sent; the frames are rendered for the token's user_id (or ?userId=), as from is relative to the end user.

  2. Long-polling on the Phoenix binding (/socket/converger/longpoll, section 2.2): the phoenix JavaScript 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:

claimmeaning
typealways converger
tenant_id, channel_idthe channel the token belongs to; the channel must be active
subconverger_CHANNEL_ID
expexpiry; default lifetime 1800 s
conversation_idoptional: restricts the token to one conversation
user_idoptional: the verified end user; also the socket identity (ConvergerWeb.Sockets)
scopeoptional: 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_id claim overrides any client-asserted userId (anti-spoofing, mekik/1). Without one, the server echoes the asserted userId (or mints one) in welcome; such an id is informational and never used for authorization.
  • A conversation_id claim fixes the conversation; an asserted different conversationId is ignored.
  • With a channel-level token (no conversation_id), an asserted conversationId is adopted only when the conversation belongs to the token's channel and tenant and, if the token carries user_id, to that user. Otherwise the server creates a new conversation, as POST /conversations does. When welcome.data.conversationId differs 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_id recorded 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 error unauthorized and 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 error channel_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 error token_revoked and 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 perms claim may only do what it lists. hello needs read (and write when it would start a conversation): otherwise error insufficient_permission and close 4403. In a session, a text frame without write or a typing frame without typing draws error insufficient_permission (details.required) and the session continues. Read receipts and sync come with read.
  • Mid-session refresh, Implemented (#26) on the native endpoint: before welcome.data.expiresAt, a client sends auth with a fresh token whose tenant, channel, conversation and user claims, and permissions (#52), match the session. The server answers tokenRefreshed. A token that does not match draws error forbidden and the session continues on the old token.
  • Expiry, Implemented (#26) on the native endpoint: at expiresAt without a refresh, the server sends error token_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:

  1. Message frames (text and every message type of messages.md, plus the Converger types event and conversationUpdate) use the mekik/1 text envelope:

    fieldtypenotes
    typestringthe message type (renderer name)
    idstringactivity id (UUID minted by Converger, or the id a mekik bot supplied)
    seqinteger1-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 side
    dataobjectthe typed payload
    timestampintegerms since the epoch (UTC); the server's commit time
    senderobject, optionalConverger: {id, role, name?}, role one of user, bot, agent, system
    metadataobject, optionalConverger activity metadata (REST channelData), at most 16 KiB
    actionsarray, optionalquick-reply chips on a bot text frame (chativa parseChatFrame rule)
    clientIdstring, optionalthe sender's clientId, so a sender's other connections can de-duplicate
  2. 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 for seq, 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​

typepersistentschemastatus
welcomenoframes/server/welcomeImplemented (#26) natively; Phoenix binding #22
text, message typesyesframes/server/messagetext: Implemented (#26) natively; rich types: Implemented (#68)
tool_call, skill, genui, interrupt, interrupt_resolvedyesframes/server/*stored and replayed: Implemented (#68); sent by an agent on a channel-scoped connection: Implemented (#64)
eventyesframes/server/eventImplemented (#26) natively
conversationUpdateyesframes/server/conversationUpdateImplemented (#26) natively
run, genui_components, skillsnoframes/server/*relayed from the bot channel: Implemented (#64) on the native endpoint and the Phoenix binding
errornoframes/server/errorImplemented (#26) on the native endpoint; on the Phoenix binding Implemented (#24) for sends (frame event); the rest #22
acknoframes/server/ackImplemented (#26) on the native endpoint and the Phoenix binding (#24)
deliveryStatusnoframes/server/deliveryStatusImplemented (#25) on the Phoenix channel binding, the native endpoint and the SSE stream (#26); v1 framing on the Phoenix binding with #22
typingnoframes/server/typingImplemented (#25) on the Phoenix channel binding, the native endpoint and the SSE stream (#26); v1 framing on the Phoenix binding with #22
presencenoframes/server/presenceImplemented (#25) on the Phoenix channel binding, the native endpoint and the SSE stream (#26); v1 framing on the Phoenix binding with #22
heartbeatnoframes/server/heartbeatImplemented (#26) on the native endpoint and SSE; Phoenix binding #22
tokenRefreshednoframes/server/tokenRefreshedImplemented (#26) on the native endpoint; Phoenix binding #22
replayTruncatednoframes/server/replayTruncatedImplemented (#26) on the native endpoint and SSE; Phoenix binding #22

5.3 Client to server​

typeschemahandled bystatus
helloframes/client/helloserverImplemented (#26) on the native endpoint; Phoenix binding #22
text and message typesframes/client/messagepersisted, then routedtext 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, editframes/client/*forwarded to the conversation's bot channelImplemented (#64) on the native endpoint and the Phoenix binding (frame event); bot_unavailable when the conversation has no bot channel
typingframes/client/typingrelayed, not storedImplemented (#25) on the Phoenix channel binding and the native endpoint (#26); v1 framing on the Phoenix binding with #22
readframes/client/readread receiptImplemented (#25) on the Phoenix channel binding and the native endpoint (#26); v1 framing on the Phoenix binding with #22
syncframes/client/syncreplay on a live connectionImplemented (#26) on the native endpoint; Phoenix binding #22
pingframes/client/pinganswered with heartbeatImplemented (#26) on the native endpoint; Phoenix binding #22
authframes/client/authtoken refreshImplemented (#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 typeframedata
messagetext{text, attachments?}: text is "" when null; non-empty attachments are carried verbatim
message with a message_type (#68)that typethe stored typed payload with the server envelope; a mekik-native type (tool_call, genui, ...) gets only seq added (messages.md section 4)
messageReaction, messageUpdate, messageDeleteevent 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_idtextas message, plus frame-level replyTo: {id}; a client text frame with replyTo.id is stored with that reply_to_id
eventevent{name?, text, value?} (for example a WhatsApp reaction to a message Converger does not know)
conversationUpdateconversationUpdatethe activity metadata, for example {event: "conversation_closed", status, reason}
endOfConversationconversationUpdate{event: "conversation_ended", status: "closed"}
typingtyping with seqisTyping: 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 error bad_request and 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 message activity with message_type = frame.type and relays it without interpreting data (section 4 of messages.md).
  • Reserved frame types, which no message may use (text excepted): 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/reservedFrameType in common.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 current activitySet replay).
  • At most limits.replayMax frames (default 10 000) are replayed per handshake or sync. Beyond that the server sends replayTruncated {watermark, headSeq} and continues live; the client fetches the rest with GET /api/v1/converger/conversations/ID/activities?watermark=N (paged by has_more, Implemented) or sends sync again.
  • An absent hello.watermark means 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, then error replay_unavailable with details.watermark (the position it could not replay from) and retryAfterMs, and continues live after the head. The client keeps that watermark as its resume point and sends sync from it after retryAfterMs; the frames of that sync are older than live frames it may already hold, so it places frames by seq. Implemented (#35); on the Phoenix binding the event error carries {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 seq it pushed. A live frame with seq greater than last + 1 makes it read the missing range from the database and push it first. Frames with seq at or below last are dropped as duplicates.
  • Client, SHOULD: a client that stamps every send with a clientId knows every seq it should see (frames plus its own acks). On a gap it sends sync {watermark: LAST_CONTIGUOUS}; the server replays seq > watermark with the same hold-back rule. A client that sends without clientId (every mekik/1 client) cannot tell its own unechoed turns from a gap and simply tracks the highest seq seen, 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).

phaseserver emitsserver accepts
Before v3.0base64url 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 stringinteger (number or decimal string), seq:N base64url, legacy activity UUID
v3.0 + one minor release (planned)integer everywhereinteger 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= and Last-Event-ID, hello.watermark, hello.watermarks, sync, read, and the Phoenix binding's join payload, ack and read.
  • 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] with surface: :opaque_watermark and api (converger, tenant, protocol or sse). No Deprecation header 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 null without 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 of A-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.
  • clientId is stored as the activity's idempotency key, namespaced by sender as ws:<sender>:<clientId>, under the unique index on (conversation_id, idempotency_key) that the REST X-Idempotency-Key header already uses (Implemented for REST, the native endpoint and the Phoenix binding's postActivity). 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 retransmitted clientId is not stored again: the server answers with the original ack plus duplicate: 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 error carrying the same clientId and is not persisted. Retryable errors (rate_limited, too_many_in_flight, unavailable) MAY be retried with the same clientId; non-retryable ones MUST NOT be.
  • At most limits.maxInFlight (32) sends per connection may be unacked; more draw too_many_in_flight. A client that reconnects re-sends its unacked sends with their original clientIds after welcome.
  • A send without clientId but with an id (mekik/1 and chativa clients stamp every outgoing message with one) is treated as if clientId were that id, provided it matches the clientId syntax above: unmodified mekik/1 clients therefore also get exactly-once persistence and an ack (which they ignore by the unknown-frame rule). If both are present, clientId wins.
  • A send with neither (or with an id that 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's activity_create rate limit bucket, shared with REST; exceeding it draws rate_limited with retryAfterMs. Validation errors draw invalid_message with details keyed 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's user_id, or "user" without one. A text frame and a postActivity with the same clientId are the same send; postActivity replies duplicate: true for a resend too. A connection's sends are persisted one at a time, in arrival order, and each ack is pushed before the sender's own activitySet of that activity. When more than maxInFlight sends arrive at once, the oldest maxInFlight are accepted and the newest are refused with too_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, regenerate and edit are forwarded to the bot channel unchanged. A user turn's meta is 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's typing is 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 error busy, interrupted, not_interrupted or incomplete_resume is relayed to the conversation's connections (the bot does not say which frame caused it, so every connection sees it);
  • a user turn whose replyTo names an open interrupt (a tapped WhatsApp button, a chativa chip) is stored as usual and reaches the bot as resume {answers: {INTERRUPT_ID: value}}, value being metadata.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).

  • welcome has scope: "channel" and channelId, and no conversationId, userId, watermark or pending.
  • Connection-level frames (hello, welcome, auth, tokenRefreshed, ping, heartbeat, and error about the connection) are sent bare.
  • Every conversation-level frame, in both directions, is wrapped as {conversationId, frame}. The envelope has no type; 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 enveloped sync later. 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.maxConversations conversations at once (default 5000); beyond that the server answers too_many_subscriptions and stops delivering live frames for the least recently active conversations.
  • The token's channel must be a websocket channel (else forbidden, close 4403). A conversation is followed when it is listed in hello.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 draws conversation_not_found in 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 from is user for the end user's activities (those that came in on the conversation's origin channel) and bot for its own and every other bot's or agent's. A user turn carries meta.channel {id, type} (the channel it came in on) and meta.participant {id}, merged with the turn.s own meta.
  • The agent sends message frames (stored as its replies, keeping a message id; a clientId or a mekik message id makes the send idempotent within the conversation and draws an ack; on a mekik-native frame the id names the interrupt and only an explicit clientId counts), mekik-native persistent frames (tool_call, skill, genui, interrupt, interrupt_resolved; a seq it sends is ignored), transient run, genui_components, skills, error (relayed, section 9), and typing, read, sync (frames/channel/agent-frame.schema.json). On a channel with require_ack, read also acknowledges the channel's deliveries. End-user-only frames (resume, ...) draw bad_request. The sender is the token's user_id, else hello.userId, else agent.
  • Agent groups (Converger addition): hello.group joins 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: user frames, 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 carry meta.channel and meta.participant but no relayed mekik meta.
    • 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.
{ "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.

limitdefaulttodayon violation
maxFrameBytes, one inbound frame128 KiBImplemented 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 connection20 per secondImplemented on the Phoenix sockets and the native endpoint (WS_MAX_MESSAGES_PER_WINDOW, #27)rate_limited with retryAfterMs
joined channels per Phoenix socket50Implemented (WS_MAX_JOINS, #27)too_many_joins reply
outbound frames buffered for a slow client1 000Implemented on the Phoenix sockets and the native endpoint (WS_SLOW_CONSUMER_QUEUE_LEN, #27)close 4503 slow_consumer
maxTextBytes, data.text64 KiBImplemented (activity_limits.max_text_bytes)payload_too_large
maxMetadataBytes, metadata as JSON16 KiBImplemented (max_metadata_bytes)payload_too_large
typed message payload as JSON128 KiBImplemented (#68) (max_payload_bytes)invalid_message
attachments (legacy text.data.attachments)10 items, 4 KiB eachImplementedinvalid_message
extensions in message data8 KiBPlanned (#68)payload_too_large
maxInFlight, unacked sends per connection32Implemented on the Phoenix binding (#24, WS_MAX_IN_FLIGHT); the native endpoint processes sends one at a time, so at most one is ever unackedtoo_many_in_flight
heartbeatIntervalMs30 000Implemented (#26) natively and on SSE (heartbeat_interval_ms); Phoenix heartbeat on the binding-
idleTimeoutMs60 000Implemented (#26) natively (idle_timeout_ms); Phoenix default on the bindingclose 4408
replayBatch100Implemented (ws_replay_limit, PAGINATION_WS_REPLAY_LIMIT)-
replayMax per handshake or sync10 000Implemented (#26)replayTruncated
maxConversations per channel-scoped socket5 000Implemented (#64) (config :converger, ConvergerWeb.Protocol, max_conversations:)too_many_subscriptions
agent group settle delay (section 10)1 000 msImplemented (#67) (config :converger, ConvergerWeb.Protocol, group_settle_ms:)-
sends per tenantactivity_create bucketImplemented 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 connection1 per 2 sImplemented (#25): repeats of the same state within 2 sdropped 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.

codenumberretryableclosesRESTmeaning
bad_request1000nono400malformed JSON, unknown type, missing or mistyped field (mekik/1)
no_session1001nono-frame before hello, or after disconnect (mekik/1)
unsupported_protocol1002no4400-hello.protocol names a version the server does not speak
invalid_watermark1003nono-watermark above the head, or a legacy form after the migration window
invalid_message1004nono422the message failed validation (details per field)
unauthorized2000no4401401missing or invalid credential (mekik/1)
token_expired2001yes, after refresh4401401the session's token expired
forbidden2002nono403the token's scope does not allow the frame or conversation
channel_inactive2003no4403400the channel was deactivated
insufficient_permission2004no4403 on hello403the token's perms claim lacks the permission the frame needs; details.required (Implemented, #52)
token_revoked2005no4401401the token was revoked or refreshed longer ago than the grace period; get a new one (Implemented, #52)
rate_limited3000yesno429rate limit hit; wait retryAfterMs (REST: Retry-After)
payload_too_large3001nono (1009 above the hard cap)413a size limit was exceeded
too_many_in_flight3002yesno-more than maxInFlight unacked sends
too_many_subscriptions3003nono-channel-scoped socket follows too many conversations
slow_consumer3004yes4503-the client reads too slowly; reconnect after retryAfterMs and resume from the watermark
conversation_closed4000nono409the conversation is closed; reopen it first
conversation_not_found4001nono404unknown conversation, or not visible to the token
busy5000yesno-relayed from the bot: a run is in flight (mekik/1)
interrupted5001nono-relayed: answer the open interrupt first (mekik/1)
not_interrupted5002nono-relayed: resume with no open interrupt (mekik/1)
incomplete_resume5003nono-relayed: resume missed an open interrupt (mekik/1)
bot_unavailable5004yesno-the frame needs a bot channel and there is none, or it is unreachable
internal9000yesno500unexpected server error (mekik/1 TS transport)
unavailable9001yes1012, 1013503the 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_unavailable9002yesno-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​

codewhen
1000normal close
1009inbound frame above the hard size cap
1011internal error
1012server restart or node draining: reconnect with backoff and the same watermark
1013try again later: the database was unavailable during the handshake (after error unavailable, #35)
4400unsupported protocol
4401unauthorized, token_expired or token_revoked (mekik/1 AUTH_CLOSE_CODE)
4403channel_inactive, insufficient_permission on hello, or the socket was force-disconnected (ConvergerWeb.Sockets, also when its token is revoked)
4408idle timeout
4503slow_consumer: too many frames buffered for the client
1008native 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​

surfacestatusremoval
/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 shipssame window as above
opaque base64url watermarksaccepted 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 stringthe 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 framedirectionConverger v1notes
helloc2sextendedall mekik/1 fields unchanged; adds protocol, watermarks (channel scope); watermark also accepts legacy forms during migration
text (user turn)c2sextendedadds optional clientId, metadata, replyTo; same frame family carries every message type
resumec2sadoptedforwarded to the bot channel; replay-from-watermark is sync
genui_eventc2sadoptedforwarded
client_toolsc2sadoptedforwarded; Converger does not interpret declarations
client_skillsc2sadoptedforwarded
abortc2sadoptedforwarded
survey, regenerate, edit (chativa connector)c2sadoptedforwarded; gated by welcome.data.capabilities
welcomes2cextendedprotocol: "converger/1", compat: ["mekik/1"]; adds scope, channelId, expiresAt, capabilities, limits; watermark is the head seq
texts2cextendedadds optional sender, metadata, clientId; from stays user or bot
rich message frames (section 4.5)s2cadoptedthe open rule is unchanged; Converger adds typed schemas for the vocabulary in messages.md
tool_calls2cadoptedbyte-identical; seq renumbered into the conversation seq space
skills2cadoptedas tool_call
genuis2cadoptedas tool_call
interrupts2cadoptedas tool_call; answers from non-mekik channels become resume; open ones are re-announced in welcome.pending
interrupt_resolveds2cadoptedas tool_call
genui_componentss2cadoptedrelayed after welcome
skillss2cadoptedrelayed after welcome
runs2cadoptedrelayed from the bot channel
errors2cextendedadds number, retryable, retryAfterMs, clientId, frameType, details; adds hub codes (section 12.2)
typing (chativa)s2cextendedalso sent by clients (c2s); adds from and sender
turn lock, busy, interrupted semantics (section 5)-not applicableowned by the bot; Converger only relays the errors
backplane envelope (section 5.1)-not applicablenode-to-node inside mekik; Converger uses Phoenix PubSub
MCP and A2A doors (sections 13, 14)-not applicablenot WebSocket frames; a mekik bot keeps serving them itself
auth close 4401 (section 7)-adoptedalso used for token_expired; mid-session refresh is an addition
query-string identity (section 1, section 7)-adoptedhints 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, tokenRefreshed or replayTruncated;
  • sends heartbeat as a WebSocket ping (section 8.5); the pong keeps the connection inside idleTimeoutMs, which a mekik/1 client that never sends ping would otherwise exceed;
  • reports token_expired and token_revoked with the mekik/1 auth code unauthorized, keeping their number (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/v1 builds and its examples validate, directly and through the direction dispatchers server-frame.schema.json and client-frame.schema.json;
  • every frame of the example sessions in priv/protocol/v1/examples/ validates, and the frames in examples/invalid.json are 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 MekikConnector and parseChatFrame (vendored in test/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 mekik adapter (Mode A, a fake mekik/1 server) and through a channel-scoped agent socket (Mode B), covering typing via run, tool calls, GenUI, text, interrupt chips answered with resume, a reconnect with the watermark that delivers exactly the missed frames once, and welcome.pending after 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.