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 connects | the mekik channel adapter opens one upstream WebSocket per conversation | the mekik transport @mekik/converger connects to a websocket channel's channel-scoped socket (WebSocket channel) |
| mekik changes | none | the @mekik/converger transport, with mekik's external-transcript profile (AimTune/mekik#47) |
| Transcripts | two: mekik records its own, Converger records again | one: Converger's |
seq spaces | two: mekik's per-conversation seq (used only as the upstream watermark) and Converger's | one: Converger's |
| Public endpoint | the mekik server must be reachable from Converger | mekik needs none |
| Status | Implemented, verified live with WhatsApp | Converger 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
-
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 -
Create a channel of type
mekik(modeduplex, 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" } } } -
Add a routing rule from the end user's channel (a WhatsApp number, the web widget's
websocketchannel, ...) to themekikchannel.
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
| Key | Required | Meaning |
|---|---|---|
url | yes | The 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. |
token | no (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. |
meta | no | A 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_id | no | The sender of the bot's activities. Default mekik. |
idle_timeout_seconds | no | A 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:
| Converger | mekik/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 socket | forwarded unchanged (protocol section 9) |
| the bot's own replies, other agents, lifecycle events | not 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 frame | Converger |
|---|---|
text with from: "bot", rich message frames | a 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, skill | message 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) |
run | relayed to the conversation's connections; run started shows the bot typing until the run ends |
genui_components, skills, error | relayed to the conversation's connections |
typing (chativa) | the bot's typing signal |
welcome | the mekik conversation and user are recorded; see resume |
error unauthorized and close 4401 | the 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 inhello(which wins). TheuserIdis the conversation's participant id (so mekik's per-user store spans the participant's conversations), elseconverger:<conversation id>; a verifieduserIdreturned by the server replaces it; - waits for
welcomebefore it sends anything (a delivery waits up tocall_timeout_ms); - reconnects after a lost connection with exponential backoff (doubling from
backoff_base_msup tomax_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_secondswithout 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
seqspaces. mekik records the conversation in its own history store, Converger records it again; Converger ignores mekik'sseqexcept 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
errorbusy(orinterruptedwhile 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 underdata.message;survey,regenerateandeditare forwarded but a stock mekik server answers them withbad_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.
| Test | Tag | What it proves |
|---|---|---|
test/converger/channels/mekik_live_test.exs | :mekik | Mode 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:
- sends
ORD-42and renders the greeting, theget_ordertraces, theorder-cardcard and the approval chips; - taps Approve (its own
resumeframe) and renders therefund_paymenttraces, the streamed text and the final reply; - disconnects, while the same user asks again from another device, so the graph runs again;
- 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.
Related
- Protocol v1, section 9: the bot channel and mekik relay and section 14: mekik/1 compatibility
- Typed messages
- WebSocket channel: channel-scoped sockets for Mode B and other agents
- ADR-0066, ADR-0063