Skip to main content

mekik

Implemented (#64, Mode A). A mekik channel makes a mekik server the bot of the conversations routed to it. Converger dials the server, one WebSocket per conversation, and speaks mekik/1 to it as a client would: any stock mekik server (for example node examples/refund.ts --serve from the mekik repository) works unchanged. The end user can be on any channel: the web widget, WhatsApp, Telegram, Slack, email, SMS. The design is recorded in ADR-0066.

end user (WhatsApp, widget, ...) ──▶ Converger ──routing rule──▶ mekik channel ══ WebSocket per conversation ══▶ mekik server
◀── reply-back routing ◀── stored bot frames ◀══ text, tool_call, interrupt, ... ══

Mode A and Mode B​

Mode A: Converger dials mekik (this page)Mode B: mekik dials Converger
Who connectsthe mekik channel adapter opens one upstream WebSocket per conversationthe mekik transport @mekik/converger connects to a websocket channel's channel-scoped socket (WebSocket channel)
mekik changesnonethe @mekik/converger transport, with mekik's external-transcript profile (AimTune/mekik#47)
Transcriptstwo: mekik records its own, Converger records againone: Converger's
seq spacestwo: mekik's per-conversation seq (used only as the upstream watermark) and Converger'sone: Converger's
Public endpointthe mekik server must be reachable from Convergermekik needs none
StatusImplemented, verified live with WhatsAppConverger side implemented (ADR-0063); @mekik/converger in review (AimTune/mekik#48); verified live (testing)

Mode B is the recommended steady state; Mode A works today with any mekik server.

Setup​

  1. Run a mekik server, for example the refund demo of the mekik repository:

    cd mekik/ts && pnpm install && pnpm build && node examples/refund.ts --serve # ws://localhost:8800
  2. Create a channel of type mekik (mode duplex, the only mode it supports) with the server's URL, in the admin panel, the tenant portal or the API:

    { "name": "refund bot", "type": "mekik", "mode": "duplex",
    "config": { "url": "wss://bot.example.com/ws", "token": "...", "meta": { "brand": "acme" } } }
  3. Add a routing rule from the end user's channel (a WhatsApp number, the web widget's websocket channel, ...) to the mekik channel.

From then on, every message of a conversation on the end user's channel reaches the bot, and the bot's answers go back to the end user by reply-back routing.

Configuration​

KeyRequiredMeaning
urlyesThe mekik/1 WebSocket endpoint, ws:// or wss://. Checked against the outbound SSRF guard (WEBHOOK_ALLOW_PRIVATE_TARGETS, WEBHOOK_ALLOWED_TARGETS, see webhooks) when the channel is saved. Credentials in the URL are refused; use token.
tokenno (secret)Sent as hello.token and as an Authorization: Bearer header (mekik/1 section 7). Masked in the admin UI; the whole config is encrypted at rest.
metanoA JSON object of mekik/1 client meta: sent as hello.meta and merged into every turn's meta (mekik/1 section 6; the server's acceptClientMeta decides what the graph sees).
bot_idnoThe sender of the bot's activities. Default mekik.
idle_timeout_secondsnoA conversation's upstream connection without traffic for this long is closed (1 to 86 400, default 300). The next turn opens a new one, which resumes.

Operator settings (config :converger, Converger.Channels.Adapters.Mekik): connect_timeout_ms (10 000), call_timeout_ms (15 000, how long a delivery waits for the connection), backoff_base_ms (1 000) and max_backoff_ms (30 000) for reconnects, auth_retry_ms (60 000, the wait after a rejected token) and relay_scan_interval_ms (60 000, see cluster).

Mapping​

End user to mekik​

The pipeline delivers the conversation's activities to the channel. The adapter renders each one as the bot sees it (ConvergerWeb.Protocol.Frames.activity/3, the end user's side is user) and sends only the end user's turns:

Convergermekik/1 frame
a text message of the end user{"type": "text", "data": {"text": "..."}, "meta": {...}} (attachments stay in data.attachments)
a rich message of the end user (an image, a shared location, a contact, ...)a text turn with the message's fallback text, carrying the typed message under data.message ({type, id, data}): mekik/1 has no other user turn, and a graph's input function can read it
a turn whose replyTo names an open interrupt (a tapped WhatsApp button, a chativa chip){"type": "resume", "answers": {"<interrupt id>": value}}, value being metadata.action.value, else the label, else the text (messages.md section 6)
a native resume, genui_event, client_tools, client_skills, abort from an end user's socketforwarded unchanged (protocol section 9)
the bot's own replies, other agents, lifecycle eventsnot sent (the delivery is marked sent with skipped: "not an end-user turn")

A turn's meta is the end user's per-turn meta (relayed ahead of the turn, never stored), under the channel's meta, under Converger's context:

{ "locale": "tr-TR", "brand": "acme",
"conversationId": "c0a8012e-...", "channel": { "id": "...", "type": "whatsapp_meta" },
"participant": { "id": "...", "externalId": "+905551112233" } }

mekik to Converger​

mekik/1 frameConverger
text with from: "bot", rich message framesa message activity of the bot (bot_id), the frame stored losslessly as a typed payload (messages.md section 4); the mekik message id is kept
text with from: "user" (fan-out echo, replay of the end user's own turn)skipped: Converger already has the turn
tool_call, genui, interrupt, interrupt_resolved, skillmessage activities with message_type = frame.type, the frame body kept verbatim; channels without a native renderer get the text fallback (an interrupt's actions become WhatsApp buttons)
runrelayed to the conversation's connections; run started shows the bot typing until the run ends
genui_components, skills, errorrelayed to the conversation's connections
typing (chativa)the bot's typing signal
welcomethe mekik conversation and user are recorded; see resume
error unauthorized and close 4401the waiting deliveries fail permanently (dead-lettered, replayable); the next attempt waits auth_retry_ms

The bot's activities are stored with the mekik channel as their source, so reply-back routing delivers them to the end user's channel, and Converger numbers them in the conversation's own seq space.

Connections​

Converger.Channels.Mekik.Connections is a DynamicSupervisor of one connection process per (mekik channel, conversation), started on the first turn delivered to the channel or the first bot-only frame of an end user. Each connection:

  • asserts its identity in the URL query string (userId, conversationId, watermark, for L7 affinity, mekik/1 section 1) and in hello (which wins). The userId is the conversation's participant id (so mekik's per-user store spans the participant's conversations), else converger:<conversation id>; a verified userId returned by the server replaces it;
  • waits for welcome before it sends anything (a delivery waits up to call_timeout_ms);
  • reconnects after a lost connection with exponential backoff (doubling from backoff_base_ms up to max_backoff_ms) plus up to 50 % random jitter; a delivery while it is backing off fails and is retried by the channel's retry policy;
  • closes after idle_timeout_seconds without traffic.

Resume and exactly-once storage​

A mekik server mints its own conversationId on the first connect and adopts an asserted one only when it exists and belongs to the connecting user. The adapter records the pair from welcome in the mekik_sessions table (one row per mekik channel and conversation), with the highest mekik seq it handled as the watermark, and asserts both on every later connect: the server replays exactly the frames Converger missed (while disconnected, while restarting). When the server hands back a different conversation (the old one is gone, for example after a restart of a mekik server with an in-memory store), the watermark starts over at 0, as mekik/1 requires.

A persistent frame with a seq at or below the watermark is skipped, and each stored frame's idempotency key names the mekik conversation and seq, so a replay is never stored twice, even when the watermark was not written yet (it is written lazily, about once a second, and when the connection stops). A frame that cannot be stored (the database is unavailable) is not acknowledged: the connection reconnects and the server replays it.

Cluster​

A connection is registered globally (:global), so a conversation has one upstream connection in the cluster. Converger.Channels.Mekik.Relay (one per node) receives the end users' bot-only frames and per-turn meta: the node that runs a conversation's connection forwards its frames; when none runs, the node chosen by rendezvous hashing of the conversation starts one. The relay follows the active mekik channels found by a scan (after boot, then every relay_scan_interval_ms) and every channel a delivery touches.

Limitations​

  • Two transcripts and two seq spaces. mekik records the conversation in its own history store, Converger records it again; Converger ignores mekik's seq except as the upstream watermark. A replayed or re-numbered mekik transcript does not change Converger's.
  • The turn lock is the bot's. A turn sent while a run is in flight draws mekik's error busy (or interrupted while the thread is parked on an interrupt); it is relayed to the end user's connections, and the delivery still counts as sent.
  • One upstream connection per conversation. Many idle conversations cost one socket each until idle_timeout_seconds.
  • User turns are text. Rich user messages reach the graph as text with the typed message under data.message; survey, regenerate and edit are forwarded but a stock mekik server answers them with bad_request.
  • The URL is checked when the channel is saved, not pinned at connect time.

Testing​

test/converger/channels/mekik_integration_test.exs runs the adapter end to end against a fake mekik/1 server (test/support/mekik/): a scripted refund turn (run, tool_call, an approval interrupt), the end user's tapped button reaching the server as resume, replay after a reconnect, resume after a restart, rejected tokens and idle eviction.

The Mode A scenario of #64 runs in CI against the fake server in test/acceptance/mekik_whatsapp_acceptance_test.exs: a whatsapp_meta channel with a routing rule to a mekik channel served by the fake server. A signed WhatsApp text webhook reaches mekik as the user turn; the tool traces stay off WhatsApp; the approval interrupt reaches the Graph API as an interactive reply-buttons message (Approve / Reject, answer keys as button ids); the button-reply webhook reaches mekik as resume {answers: {int-1: {approved: true}}}; interrupt_resolved is stored and the final text reaches WhatsApp. A typed reply "2" resumes the graph with the second action (Reject) the same way.

chativa through Mode A, verified (#63, #65). test/protocol/chativa_live_bot_test.exs puts the unmodified @chativa/connector-mekik on the widget side of this setup (a websocket channel routed to a mekik channel served by the fake server, started with genui: true), and runs the same scenario through a Mode B agent socket. chativa renders the bot's live run as typing, the tool_call traces, a GenUI chunk, the text and the interrupt as chips. A tapped chip goes out as chativa's resume and reaches the mekik server unchanged, and the bot's final text renders. chativa then reconnects with its watermark and gets exactly the missed frames once, and after a page reload welcome.pending re-renders the open interrupt (connecting chat clients). It runs in the CI job "chativa reference client compatibility".

Live tests against the mekik repository​

Three tests run the real mekik code instead of the fake server. They need a checkout of the mekik repository with ts/ built (pnpm install && pnpm build), so test/test_helper.exs excludes their tags by default and CI does not run them; run them by hand when the adapter, the bot relay, the channel-scoped socket or the mekik/1 profile changes.

TestTagWhat it proves
test/converger/channels/mekik_live_test.exs:mekikMode A with a widget user: the stock refund demo answers, pauses on the approval and resumes on the answer.
test/acceptance/mekik_whatsapp_live_test.exs:mekik#64 Mode A acceptance: a stock node examples/refund.ts --serve, configured as a mekik channel, answers a WhatsApp user through a routing rule.
test/protocol/mekik_converger_live_test.exs:mekik_converger#64 Mode B acceptance: the same demo behind Converger with @mekik/converger; Converger is the only transcript, and a reconnecting chativa replays exactly once.

Mode A (start the demo first, it listens on port 8800):

cd mekik/ts && node examples/refund.ts --serve
MEKIK_URL=ws://127.0.0.1:8800/ws mix test --only mekik

The WhatsApp test posts signed Meta webhooks to /api/v1/channels/:id/inbound and answers the Graph API with a Req.Test stub. The user writes ORD-42; WhatsApp gets the demo's greeting and then the approval interrupt as interactive reply buttons (Approve / Reject, Converger's answer keys as button ids). The get_order tool traces and the order-card GenUI card are stored but never sent. A signed button_reply webhook resumes the graph (interrupt_resolved with the interrupt's id, the refund_payment traces), and WhatsApp gets the streamed text (Refund processed ✅, one message when the GenUI stream is done) and the reply Refund complete: ORD-42. A second refund in the same conversation pauses on an interrupt with the same id (approve:interrupt#0, mekik names a pause by its node): a tap on the first message's button is refused by mekik as a new turn while parked, and the new buttons resume the graph.

Mode B (the test spawns the demo itself and kills it on exit; Node >= 22.18):

MEKIK_DIR=path/to/mekik mix test --only mekik_converger # MEKIK_DIR defaults to ../mekik

The test serves the endpoint on a random port, creates a websocket widget channel routed to a websocket bot channel, and runs node examples/refund.ts --converger with CONVERGER_URL=ws://127.0.0.1:PORT/socket/converger/v1 and CONVERGER_SECRET set to the bot channel's secret. The transport mints its channel-scoped token with POST /api/v1/converger/tokens/generate on the same origin and dials the bot channel's socket. The vendored chativa connector (see connecting chat clients) then:

  1. sends ORD-42 and renders the greeting, the get_order traces, the order-card card and the approval chips;
  2. taps Approve (its own resume frame) and renders the refund_payment traces, the streamed text and the final reply;
  3. disconnects, while the same user asks again from another device, so the graph runs again;
  4. reconnects with its watermark and receives exactly the missed frames, each once. The open interrupt is also re-announced in welcome.pending (as a mekik server does) under the same message id, which chativa's message store renders once; a further reconnect replays nothing. The chips resume the second run.

Converger's transcript then holds every frame once, in one contiguous seq space (each tool call stored exactly twice, running and completed); mekik keeps no history of its own: the demo runs with the external-transcript profile, which connectConverger requires, and its transport reports no error.