Connecting chat clients (chativa)
chativa is Converger's reference WebSocket client
(#65,
ADR-0065). Because
Converger Protocol v1 is a superset of mekik/1, the unmodified @chativa/connector-mekik works against
the native endpoint, and a compatibility suite keeps it that way: every pull request runs chativa's real
MekikConnector and parseChatFrame in Node against a Converger server
(test/protocol/chativa_compat_test.exs, CI job "chativa reference client compatibility"). A Converger change that
breaks chativa fails the build.
The hub extras that mekik does not have (delivery receipts, history paging, uploads, the conversation list for agent
consoles) are served over REST and the full protocol, for @chativa/connector-converger, a thin extension of
MekikConnector built in AimTune/chativa#86 (Planned).
Widget setup with connector-mekik: Implemented (#65)
-
Token, on your backend. Never ship the channel secret to the browser. A small endpoint of yours calls
POST /api/v1/converger/tokens/generatewith the channel secret and the signed-in user, and returns the token (client API):curl -s -X POST "$CONVERGER/api/v1/converger/tokens/generate" \-H "authorization: Bearer $CHANNEL_SECRET" \-H "content-type: application/json" \-d '{"user": {"id": "user-42"}}' -
Conversation. Either create one with
POST /api/v1/converger/conversations(it returnsconversationIdand a conversation-bound token, which is the better token to hand to the widget), or let the socket create one: ahellowithoutconversationIdand a channel-level token starts a new conversation, andwelcomenames it. -
Connector. Point
MekikConnectorat the native endpointwss://HOST/socket/converger/v1:import { MekikConnector, TokenAuth } from "@chativa/connector-mekik";const connector = new MekikConnector({url: "wss://chat.example.com/socket/converger/v1",// A function, so a rejected or expired token is replaced by a fresh one.auth: new TokenAuth({token: async () => (await (await fetch("/api/chat-token")).json()).token,}),conversationId, // from POST /conversations, or omit to let the server create onerouteInUrl: true, // conversationId in the query string, for L7 affinityresumeConversation: true, // keep the id and the watermark across reloads});- Do not set
protocols: a client that offers no subprotocol and sends nohello.protocolgets the mekik/1 profile. - The token travels in
hello.token(theTokenAuthdefault).transport: "query"(?token=) also works. routeInUrlputsconversationId(anduserId) in the URL. Converger treats them as hints for a load balancer that routes by conversation; the token decides (v1 section 2.1).- The
userIdthatwelcomereturns is the token'suser_idwhen it has one; a client-asserteduserIdnever overrides it.
- Do not set
Publishable keys
Step 1 needs a backend to hold the channel secret. A site without one (static site, CMS) uses the channel's
publishable key instead: Implemented on the hub (#195,
ADR-0074); the chativa side is PublishableKeyAuth in
@chativa/connector-converger (AimTune/chativa#91, Planned until
that pull request is released).
import { ConvergerConnector, PublishableKeyAuth } from "@chativa/connector-converger";
const connector = new ConvergerConnector({
url: "wss://chat.example.com/socket/converger/v1",
auth: new PublishableKeyAuth({
baseUrl: "https://chat.example.com",
publishableKey: "cvg_pk_...",
}),
});
PublishableKeyAuth calls POST /api/v1/converger/tokens/publishable, keeps the resume credential in
localStorage (per key) and gives the connector the conversation-bound token and its conversation id, so a reload
continues the same conversation. When the socket rejects an expired token it calls the endpoint again with resume.
The page's origin must be in the key's allowed origins. MekikConnector accepts the same auth: the server names
the conversation in welcome.
To try a channel without a key or a backend, paste a token from the channel page's Test token action (admin UI) into chativa's sandbox (Token credential mode).
What works
| chativa / mekik/1 | over Converger | status |
|---|---|---|
hello / welcome, identity adopted, server-minted conversation | as mekik/1; welcome.data.watermark is the head seq | Implemented |
Watermark resume (resumeConversation) | replay of every persistent frame after hello.watermark; open interrupts re-announced in welcome.data.pending | Implemented (pending: #65) |
User turns (sendMessage) | stored once: the message id chativa stamps is used as the clientId (v1 section 7) | Implemented |
Bot and agent messages, from: "user" replays of the user's own turns | text frames; from is relative to the token's user_id | Implemented |
Quick-reply chips (text with actions) | parseChatFrame turns them into a quick-reply with keepActions | Implemented |
Rich message types (buttons, card, carousel, image, file, video, ...) | stored and replayed by name; chativa renders them from its registry (messages) | Implemented (#68) |
tool_call, genui, interrupt, interrupt_resolved | stored and replayed byte-identically except seq; chativa shows the trace, mounts GenUI, renders interrupt chips | Implemented (#68) |
typing from other participants | the flat typing frame parseChatFrame expects | Implemented (#25) |
| Auth rejection, expiry | error unauthorized and close 4401; TokenAuth with a function fetches a new token and reconnects | Implemented |
regenerate / edit buttons | hidden: welcome.data.capabilities reports them as false | Implemented |
Live frames from a mekik bot behind Converger: run (typing), genui_components, skills, live tool_call / genui / interrupt | relayed from the bot channel (v1 section 9) | Implemented (#64), verified live (#65) |
resume, genui_event, client_tools, client_skills, abort, survey | forwarded to the bot channel; without one, answered with error bot_unavailable, which connector-mekik ignores | Implemented (#64); resume verified live (#65) |
The suite checks each "Implemented" row with the real connector: a replayed transcript of every frame family, a live message and a typing indicator, a send that is persisted once, an idle connection kept open by pings, the REST history in frame form, and agent-panel switching.
A mekik bot behind Converger: verified live (#63, #65)
test/protocol/chativa_live_bot_test.exs (same CI job) puts a mekik bot behind Converger and talks to it through the
unmodified connector, on a websocket widget channel routed to the bot. It runs once per bot mode:
- Mode A: a
mekikchannel. The adapter dials a mekik/1 server (a scripted fake in the tests). - Mode B: an agent on a channel-scoped socket (v1 section 10) that sends the same mekik frames.
What chativa's UI callbacks receive, in order:
- The user sends a text. The bot's
run startedturns typing on, thenonToolCallgets thetool_callrunning and completed,onGenUIChunkgets the GenUI chunk,onMessagegets the bot'stextand theinterruptas a quick reply with one chip per action.run interruptedturns typing off. - The user taps a chip. connector-mekik sends its own
resumeframe (answers: {<interrupt id>: <action value>}), which the bot receives unchanged. The bot'sinterrupt_resolvedrenders nothing, and its finaltextrenders. - The connector disconnects, the bot sends two more frames, and the connector reconnects with its watermark. It renders exactly those two frames, once.
- A page reload while the bot waits on the interrupt (
resumeConversation, so the stored watermark is already past the interrupt): nothing is replayed,welcome.data.pendingre-announces the interrupt, connector-mekik renders the same chips, and a tap still resumes the bot.
With the real mekik code instead of scripted frames, test/protocol/mekik_converger_live_test.exs (tag
:mekik_converger, excluded by default because it needs the mekik repository, see
live tests) runs the stock refund demo behind
Converger with @mekik/converger and drives the same connector through two refunds. On a reconnect while the second
run waits on its approval, the connector gets welcome.data.pending and then the replay, both carrying the open
interrupt under the same message id (interrupt-<id>); chativa's message store renders an id once, so the chips
appear once. mekik names a pause by its node (approve:interrupt#0), so every run of a graph reuses the same
interrupt id: a page that already rendered the first run's chips and is not reloaded (chativa's store remembers
rendered ids) does not render the second run's chips again.
The mekik/1 profile
mekik/1 has an open rule: a frame type the client does not know is a rich message, rendered by name. connector-mekik
follows it, so Converger's additive frames (ack, deliveryStatus, ...) would appear as empty chat bubbles. The
native endpoint therefore serves a mekik/1 profile to a connection that negotiated no WebSocket subprotocol and
whose hello has no protocol (or "mekik/1") (v1 section 14.1):
- it does not send
ack,deliveryStatus,presence,tokenRefreshedorreplayTruncated; - it sends
heartbeatas a WebSocket ping, which the browser answers with a pong, so an idle widget is not closed with 4408; - it reports
token_expiredandtoken_revokedwith the mekik/1 codeunauthorized(thenumberkeeps the Converger code), so the auth provider fetches a fresh token.
Persistent event and conversationUpdate frames (reactions, edits, a conversation closing) are still delivered:
chativa renders them with its fallback component unless you register renderers named event and
conversationUpdate, or drop them with an afterReceive extension.
A client that offers the converger.v1 subprotocol, or sends hello.protocol: "converger/1", gets the full
protocol; that is how @chativa/connector-converger receives receipts and acks.
Hub extras
The capabilities of chativa's IConnector that connector-mekik leaves empty, and what Converger serves for each.
@chativa/connector-converger (AimTune/chativa#86, Planned)
implements them; the Converger side is Implemented (#65) unless marked otherwise.
IConnector | Converger | notes |
|---|---|---|
onMessageStatus | deliveryStatus frames (v1 section 8.1) | full protocol only (converger.v1). Map queued and sent to chativa's sent, read to read; the frame names the activity id and seq, and ack maps a clientId to them |
sendFile | POST /api/v1/converger/conversations/:id/upload (client API) | the response carries seq and frame, the attachment message as the socket delivers it, so the widget can show it at once and drop the socket copy by id |
loadHistory(cursor) | GET .../activities?direction=backward&format=frames, then ?before=<cursor>&format=frames (history paging) | pages are oldest first; cursor is a seq; frames go through the same routing as live frames |
listConversations | GET /api/v1/converger/conversations with a channel-scoped token (list) | most recently active first; title, contact, lastMessage, lastMessageAt as chativa names them; map active to open |
createConversation(title) | POST /api/v1/converger/conversations with {"title": ...} | the title is stored as metadata.title |
switchConversation(id) | GET /api/v1/converger/conversations/:id with the channel-scoped token, then reconnect with the returned conversation-bound token and conversationId: id | one socket per open conversation today; one multiplexed channel-scoped socket is Planned (#64, v1 section 10) |
closeConversation(id) | POST /api/v1/converger/conversations/:id/close | a conversationUpdate frame announces it |
onConversationUpdate | conversationUpdate frames on open sockets; refresh the list with updated_since | per-conversation updates on the multiplexed socket are Planned (#64) |
sendFeedback | POST .../activities with type: "messageReaction", replyToId and the reaction as text |
Agent-panel mode
chativa's agent-panel mode (one agent, many conversations) runs on a channel-scoped token:
- The agent console's backend mints it:
POST /api/v1/converger/tokens/generatewith the channel secret and{"scope": "channel", "user": {"id": "agent-7"}}. listConversationspagesGET /api/v1/converger/conversations(filters:status,updated_since).switchConversationexchanges the channel-scoped token for a conversation-bound one (GET /api/v1/converger/conversations/:id, same user and permissions) and reconnects the connector with it. The agent's turns are stored with senderagent-7, so the customer sees them asfrom: "bot".
Implemented (#65) as above, and covered by the compatibility suite. Planned (#64): a single channel-scoped
native socket that carries every conversation of the channel in {conversationId, frame} envelopes, the agent
participant role in typing and presence, and conversation-level updates on it; connector-converger will switch to
it without a new socket per conversation.