Skip to main content

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)​

  1. Token, on your backend. Never ship the channel secret to the browser. A small endpoint of yours calls POST /api/v1/converger/tokens/generate with 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"}}'
  2. Conversation. Either create one with POST /api/v1/converger/conversations (it returns conversationId and a conversation-bound token, which is the better token to hand to the widget), or let the socket create one: a hello without conversationId and a channel-level token starts a new conversation, and welcome names it.

  3. Connector. Point MekikConnector at the native endpoint wss://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 one
    routeInUrl: true, // conversationId in the query string, for L7 affinity
    resumeConversation: true, // keep the id and the watermark across reloads
    });
    • Do not set protocols: a client that offers no subprotocol and sends no hello.protocol gets the mekik/1 profile.
    • The token travels in hello.token (the TokenAuth default). transport: "query" (?token=) also works.
    • routeInUrl puts conversationId (and userId) in the URL. Converger treats them as hints for a load balancer that routes by conversation; the token decides (v1 section 2.1).
    • The userId that welcome returns is the token's user_id when it has one; a client-asserted userId never overrides it.

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/1over Convergerstatus
hello / welcome, identity adopted, server-minted conversationas mekik/1; welcome.data.watermark is the head seqImplemented
Watermark resume (resumeConversation)replay of every persistent frame after hello.watermark; open interrupts re-announced in welcome.data.pendingImplemented (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 turnstext frames; from is relative to the token's user_idImplemented
Quick-reply chips (text with actions)parseChatFrame turns them into a quick-reply with keepActionsImplemented
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_resolvedstored and replayed byte-identically except seq; chativa shows the trace, mounts GenUI, renders interrupt chipsImplemented (#68)
typing from other participantsthe flat typing frame parseChatFrame expectsImplemented (#25)
Auth rejection, expiryerror unauthorized and close 4401; TokenAuth with a function fetches a new token and reconnectsImplemented
regenerate / edit buttonshidden: welcome.data.capabilities reports them as falseImplemented
Live frames from a mekik bot behind Converger: run (typing), genui_components, skills, live tool_call / genui / interruptrelayed from the bot channel (v1 section 9)Implemented (#64), verified live (#65)
resume, genui_event, client_tools, client_skills, abort, surveyforwarded to the bot channel; without one, answered with error bot_unavailable, which connector-mekik ignoresImplemented (#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 mekik channel. 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:

  1. The user sends a text. The bot's run started turns typing on, then onToolCall gets the tool_call running and completed, onGenUIChunk gets the GenUI chunk, onMessage gets the bot's text and the interrupt as a quick reply with one chip per action. run interrupted turns typing off.
  2. The user taps a chip. connector-mekik sends its own resume frame (answers: {<interrupt id>: <action value>}), which the bot receives unchanged. The bot's interrupt_resolved renders nothing, and its final text renders.
  3. The connector disconnects, the bot sends two more frames, and the connector reconnects with its watermark. It renders exactly those two frames, once.
  4. 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.pending re-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, tokenRefreshed or replayTruncated;
  • it sends heartbeat as a WebSocket ping, which the browser answers with a pong, so an idle widget is not closed with 4408;
  • it reports token_expired and token_revoked with the mekik/1 code unauthorized (the number keeps 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.

IConnectorConvergernotes
onMessageStatusdeliveryStatus 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
sendFilePOST /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
listConversationsGET /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: idone socket per open conversation today; one multiplexed channel-scoped socket is Planned (#64, v1 section 10)
closeConversation(id)POST /api/v1/converger/conversations/:id/closea conversationUpdate frame announces it
onConversationUpdateconversationUpdate frames on open sockets; refresh the list with updated_sinceper-conversation updates on the multiplexed socket are Planned (#64)
sendFeedbackPOST .../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:

  1. The agent console's backend mints it: POST /api/v1/converger/tokens/generate with the channel secret and {"scope": "channel", "user": {"id": "agent-7"}}.
  2. listConversations pages GET /api/v1/converger/conversations (filters: status, updated_since).
  3. switchConversation exchanges 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 sender agent-7, so the customer sees them as from: "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.