ADR-0024: Converger Protocol v1 is a superset profile of mekik/1
| Status | Accepted |
| Date | 2026-10-08 (decision in #63), 2026-10-09 (spec and owner review) |
| Issues | #21 (spec), #63 (mekik/1 compatibility), #68 (rich message vocabulary) |
| Pull request | #106 (docs/protocol/v1.md, docs/protocol/messages.md, priv/protocol/v1/) |
| Related | ADR-0006, ADR-0017, ADR-0018, ADR-0020, ADR-0013 |
Context
Converger's WebSocket wire format was implicit in ConvergerChannel and ConversationChannel:
Phoenix framing, activitySet frames, an opaque base64 watermark, no acks, no error codes, no
version. The #21 draft proposed its own frames (activitySet, ack, send, resume {watermark}).
Two sibling projects already define most of it. mekik/1 (AimTune/mekik, PROTOCOL.md) has a
hello/welcome handshake, a persistent frame envelope with a per-conversation seq, replay after
a watermark, interrupts and an open rich message rule, with two implementations held together by
golden fixtures. chativa (AimTune/chativa) renders typed messages and parses mekik frames with
@chativa/connector-mekik. If Converger diverged, every chativa user would need a third connector,
and a mekik bot could not sit behind Converger without a translation layer tracking two specs.
#63 decided that Converger Protocol v1 is a superset profile of mekik/1. Writing the spec required
further decisions that the issues left open; they are recorded here.
Considered options
- Own protocol (the #21 draft): full freedom, but a third dialect for chativa and a translation layer for mekik bots.
- mekik/1 unchanged: maximum compatibility, but no acks, receipts, presence, agent sockets or hub error codes.
- Superset profile of mekik/1 (chosen): identical where mekik/1 defines something, additive elsewhere, with every addition ignorable under mekik/1's unknown-field and unknown-frame rule.
Decision
Option 3, with these specific choices:
- Version negotiation.
welcome.data.protocolis"converger/1"andcompatlists"mekik/1". Native endpoint: subprotocolconverger.v1; a client offering no subprotocol is accepted with mekik/1 semantics. Phoenix binding:vsnstays the Phoenix serializer version, andprotocol: "converger/1"in the join payload selects v1 (eventframe); without it the legacyactivitySetshape continues. fromkeeps mekik's two values."user"or"bot", relative to the end user, so chativa renders the right side. Hub roles (user,bot,agent,system) and the sender id go in an optionalsenderobject.- mekik-native persistent frames get no Converger fields.
tool_call,skill,genui,interruptandinterrupt_resolvedkeep exactly mekik's shape (noid,fromortimestampadded), so relayed frames round-trip byte-identically exceptseq, which is renumbered into the conversation's seq space. They are addressed by(conversationId, seq). - No
sendframe. A user turn is mekik's typed message frame (text, generalised to every message type) with an optionalclientId.resume {watermark}is renamedsync, because mekik'sresumeanswers interrupts. - Watermark = integer
seq.welcome.data.watermarkis the conversation's head seq. A missing watermark means 0 (full, bounded replay) as in mekik/1, unlike today's live-only Converger behaviour. Migration: v3.0 (#22) emits integers in v1 frames and in REST, and accepts integers, decimal strings and both legacy base64url forms; one minor release later only integers are accepted. The forms are unambiguous (base64url watermarks start with a letter). - Bounded replay without breaking mekik clients. The server replays in batches of
ws_replay_limit(100) up toreplayMax(10 000) per handshake, then sendsreplayTruncated; the REST activity list withhas_morecovers the rest. mekik clients see a full replay in practice. - Gaps. The server fills gaps itself (Phoenix PubSub is at-most-once): a live frame beyond
last + 1makes it read the missing range from the database first. Clients that stamp every send withclientIdcan also detect gaps andsync. mekik clients (noclientId, no echo of their own turn) only track the highestseq. - Acks and dedupe.
ack {clientId, id, seq, timestamp}only for sends with aclientId.clientIdis stored as the existing activity idempotency key (unique per conversation), so a retransmission returns the original ack withduplicate: true. At most 32 unacked sends per connection. - Errors. mekik's
error {code, message}plusnumber(ranges: 1xxx protocol, 2xxx auth, 3xxx limits, 4xxx conversation state, 5xxx relayed from the bot, 9xxx server),retryable,retryAfterMs,clientId. Close codes: 4401 forunauthorizedandtoken_expired(so mekik connectors' auth providers refresh), 4403 inactive channel, 4408 idle, 1012 draining. - Mid-session token refresh with an
authframe (tokenRefreshedreply); on expirytoken_expiredand close 4401, and the client reconnects with the same watermark. - Legacy activity types.
messagebecomestext(attachments carried verbatim until #28),eventandconversationUpdateare persistent Converger frame types,endOfConversationmaps toconversationUpdate, and typing activities persisted before #25 are delivered astypingframes that carry aseq(isTyping: falseon replay), so no seq is ever skipped. - Channel-scoped sockets. An explicit
scope: "channel"claim (not just a token withoutconversation_id, which today is used by end-user widgets) enables{conversationId, frame}multiplexing; the envelope has notype, which distinguishes it from bare connection frames. Resume withhello.watermarksor an envelopedsync. - Message vocabulary. chativa's type names and
datafields win where they exist (for example card actions arebuttons, notactions); Converger adds only optional fields (a card body istext).data.textis the universal fallback (chativa's fallback renderer shows it);data.extensionsis the namespaced provider escape hatch; unknown renderer-named types are stored opaquely. - Schemas are JSON Schema 2020-12 under
priv/protocol/v1with$ids underhttps://converger.aimtune.dev/schemas/protocol/v1/, validated in CI bytest/protocol(JSV, test-only dependency).
Consequences
- An unmodified
@chativa/connector-mekikcan connect to the native endpoint once #22 ships; chativa needs no Converger-specific connector. - Converger cannot change any mekik/1-defined shape without breaking that promise; changes go through mekik first or become additive Converger fields.
- Clients of the REST API see the watermark become an integer in v3.0 (a JSON type change), announced in the changelog; old watermarks keep working for one more release.
- The turn lock stays with the bot; Converger only relays
busyandinterrupted. - Two socket stacks and the legacy shapes stay until #23 and the deprecation window ends.
- Decided by the owner during review (2026-10-09):
- the native endpoint is
wss://HOST/socket/converger/v1; - the
idthat mekik/1 and chativa clients stamp on outgoing messages counts as theclientIdwhen no explicitclientIdis sent, so unmodified clients get exactly-once persistence and acks (dedupe on(conversation_id, idempotency_key)); - the default limits stay as specified (128 KiB frames, 32 unacked sends, 10k replay cap, 5,000 conversations per agent socket, 30 s heartbeat, 60 s idle timeout), all configurable;
- the message vocabulary follows chativa's names where #68 differed (card actions are
buttons, the card body isdata.text).
- the native endpoint is
- Still open: how agent sockets discover conversations they missed while disconnected (specified as per-conversation watermarks plus REST for now).