MekikConnector
Client adapter for mekik servers — Mekik Wire Protocol v1. Mekik is Chativa's server-side sibling: Chativa renders the chat, mekik runs the conversation. Because both sides speak one protocol, this connector gets identity handshake, watermark resume, tool-call lifecycle, typing, Generative UI streaming and human-in-the-loop chips with no per-app glue.
import { MekikConnector } from "@chativa/connector-mekik";
import { ConnectorRegistry, chatStore } from "@chativa/core";
ConnectorRegistry.register(
new MekikConnector({
url: "wss://bot.example.com/chat",
resumeConversation: true,
})
);
chatStore.getState().setConnector("mekik");
The wire protocol is transport-agnostic on the mekik side, but this connector speaks the WebSocket profile.
Options
Schema: schemas/connectors/mekik.schema.json.
| Field | Default | Description |
|---|---|---|
url | — | Required. mekik WebSocket endpoint, e.g. wss://bot.example.com/chat. |
protocols | [] | Sub-protocol(s) passed to the WebSocket constructor. |
reconnect | true | Auto-reconnect when the socket drops. Suppressed after an auth rejection. |
reconnectDelay | 2000 | Milliseconds between reconnect attempts. |
maxReconnectAttempts | 5 | Give up after this many consecutive failures. Reset on a successful handshake. |
queueOfflineMessages | true | Hold outgoing messages while the socket is down and flush them on (re)connect. |
userId | server-minted | Stable user identity. Omit to let the server generate one (announced via welcome). |
conversationId | server-minted | Conversation to resume. Omit to start a new one. |
resumeConversation | false | Persist identity + watermark in localStorage and resume across page reloads. |
auth | — | How this client authenticates — a MekikAuthProvider adapter (CookieAuth, TokenAuth, or your own). Omit for servers that don't authenticate. See Authentication. |
token | — | Deprecated — shorthand for auth: new TokenAuth({ token, maxRetries: 0 }). Still works. |
onAuthError | — | Called when the server rejects the connection, whichever adapter is in use. |
Frame mapping
Every mekik transport carries the same JSON frames. The connector routes them like this:
| mekik frame | Direction | Becomes |
|---|---|---|
hello | client → server | Sent on open — userId / conversationId / watermark / token. All fields optional. |
welcome | server → client | Identity + current watermark. Captured on connector.identity, never rendered as a message. |
text (bot) | server → client | text message bubble. |
text + actions | server → client | quick-reply message — chips render natively; tapping one answers the bot. |
text (from: "user") | server → client | Transcript replay / other-tab fan-out of the user's own messages. |
tool_call | server → client | onToolCall — same id upserted running → completed / error. |
run { status } | server → client | Typing indicator: started → on, finished → off. |
genui | server → client | onGenUIChunk — mounts a GenUI component inline. |
genui_event | client → server | Sent when a mounted GenUI component fires an event (form submit, card action…). |
error | server → client | Auth rejection — surfaced via onAuthError, followed by close code 4401. |
survey | client → server | sendSurvey() payload. |
regenerate | client → server | regenerate() — { type: "regenerate", messageId }. Only sent to servers that advertise it. |
edit | client → server | editMessage() — { type: "edit", messageId, data }. Only sent to servers that advertise it. |
Unknown frame types and unknown fields are ignored on both sides — that's the protocol's forward-compatibility rule, so a newer server can't break an older client.
Identity and resume
mekik identity is a triple: userId, conversationId, connectionId. You may assert any of them in the hello handshake; the server fills in whatever you omit and announces the result in welcome:
const connector = new MekikConnector({ url: "wss://bot.example.com/chat" });
await connector.connect();
connector.identity; // { conversationId, userId, connectionId, watermark }
Server-minted ids are adopted automatically, so the next reconnect resumes the same session.
Watermark replay. Persistent frames (text, tool_call, genui) carry a monotonic seq. The connector tracks the highest one it has seen and hands it back on reconnect, so the server replays only what you missed — the same resume model as DirectLine, and it covers multi-tab and multi-device out of the box.
With resumeConversation: true the identity and watermark are persisted to localStorage under chativa:mekik:<url>, so a page reload rejoins the same conversation mid-transcript. If the server answers with a different conversationId than the one you tried to resume (the old one expired, say), the watermark resets to zero — the old watermark belongs to a transcript that no longer exists.
Authentication
By default mekik accepts every connection and identity is client-asserted. A server opts into authentication by configuring an Authenticator (PROTOCOL.md §2.1); that gate then applies to connect().
Authentication is a port here, mirroring the server. Mekik's Authenticator (in @mekik/core) decides whether a connection may open; Chativa's MekikAuthProvider decides what credential the client presents. You pass an adapter as auth and the connector stays out of it:
import { MekikConnector, CookieAuth, TokenAuth } from "@chativa/connector-mekik";
new MekikConnector({ url, auth: new CookieAuth() }); // cookie session
new MekikConnector({ url, auth: new TokenAuth({ token: "api-key" }) }); // API key / JWT
The split matters because a cookie session, a static API key and a short-lived JWT all reach the same server through the same wire fields, but they're obtained — and refreshed — in completely different ways. That variation belongs in the adapter, not in the connector.
| Chativa adapter (client) | mekik adapter (server) | Credential |
|---|---|---|
CookieAuth | CookieAuthenticator | The browser's cookie — nothing to send |
TokenAuth (string) | StaticTokenAuthenticator | A long-lived API key |
TokenAuth (function) | HmacJwtAuthenticator | A short-lived JWT, re-minted per attempt |
your own MekikAuthProvider | your own Authenticator | anything |
The provider is consulted before every socket, never once and cached — which is what lets a reconnect carry a credential that's still valid.
Cookie sessions
If your server uses CookieAuthenticator, the client sends nothing: browsers attach cookies to the WebSocket handshake themselves, and mekik forwards request headers to its authenticator. This is the best option for same-site browser apps — the credential stays HttpOnly and never touches JavaScript.
new MekikConnector({
url: "wss://bot.example.com/chat",
auth: new CookieAuth({
// Optional: renew an expired session, then retry the connection once.
refresh: () => fetch("/api/session/refresh", { method: "POST" }),
}),
});
CookieAuth contributes no credential by design; it exists to declare that intent in app code and to give an expiring session somewhere to refresh from. Omitting auth entirely works identically for a server that authenticates by cookie.
Token credential
new MekikConnector({
url: "wss://bot.example.com/chat",
auth: new TokenAuth({ token: "eyJhbGciOiJIUzI1NiIs..." }),
});
A static string is only right for a long-lived credential. For short-lived JWTs pass a function — it runs on every attempt, so each one carries a fresh token instead of the one that was valid at page load:
auth: new TokenAuth({
token: async () => {
const res = await fetch("/api/chat-token", { credentials: "include" });
return (await res.json()).token;
},
});
This matters most after a long disconnect: the reconnect when a laptop wakes up is exactly when a page-load-time token has expired.
If the function throws, connect() rejects with that error. The connector deliberately does not fall back to connecting anonymously, which would report a token-endpoint outage as a misleading "unauthorized" from the server.
Transport
transport | Credential rides in | Use when |
|---|---|---|
"hello" (default) | The hello frame's token | Almost always. |
"query" | ?token= on the socket URL | Something upstream of mekik — an edge proxy or gateway authenticating at the HTTP upgrade — must read it. It never sees the hello frame. |
auth: new TokenAuth({ token, transport: "query", queryParam: "access_token" });
Prefer "hello": a query token lands in server and proxy access logs. (mekik also accepts an Authorization: Bearer header, but browsers can't set headers on a WebSocket handshake, so it isn't reachable from this connector.)
Rejection and retry
When the authenticator returns ok: false, mekik sends an error frame and closes with WebSocket code 4401:
{ "type": "error", "data": { "code": "unauthorized", "message": "invalid token" } }
The connector surfaces that through onAuthError, and blind auto-reconnect stays off — only the provider can know whether a better credential is obtainable, so it makes the call via onReject:
TokenAuthwith a function retries once by default (maxRetries), re-minting the token. A static string never retries — re-sending a string the server just refused cannot change the verdict.CookieAuthretries once if you gave it arefresh, and gives up if the refresh itself fails.
So the common expired-JWT flow needs no app code at all:
new MekikConnector({
url,
auth: new TokenAuth({ token: () => auth.freshAccessToken() }), // rejected → re-mint → retry
onAuthError: (err) => redirectToLogin(err), // still fires; for terminal failures
});
The last rejection is readable at connector.authError (null unless the server refused this connection); it's cleared on the next connect(). An auth rejection is not a chat message — it never lands in the transcript.
Writing your own provider
Implement the one-method port — the client mirror of writing your own Authenticator on the server:
import type { MekikAuthProvider } from "@chativa/connector-mekik";
const oidcAuth: MekikAuthProvider = {
name: "oidc",
async authenticate({ attempt, previousError }) {
const token = await oidc.getAccessToken({ forceRefresh: attempt > 0 });
return { token }; // or { query: { token } } for the query transport
},
onReject: (error, ctx) => (ctx.attempt < 2 ? "retry" : "fail"),
};
new MekikConnector({ url, auth: oidcAuth });
authenticate() receives the attempt number and the rejection that caused the retry, and returns a MekikCredential — { token }, { query }, or {} for "nothing to send".
Verified identity
If the authenticator returns a userId, that is the verified identity and it overrides whatever the client asserted in hello — a valid token cannot be used to impersonate another user. Read the result from the welcome frame:
await connector.connect();
connector.identity?.userId; // the id the server verified, not necessarily the one you sent
Any claims the authenticator returns stay server-side, exposed to the runtime as TurnContext.meta.auth — they are not sent to the client.
Pairing with the server
new MekikConnector({ auth: new CookieAuth({ refresh }) }) pairs with a mekik server configured like this:
import { ConversationEngine } from "@mekik/core";
import { CookieAuthenticator, HmacJwtAuthenticator } from "@mekik/authentication";
const engine = new ConversationEngine({
runtime,
authenticator: new CookieAuthenticator({
cookie: "mekik_session",
inner: new HmacJwtAuthenticator({ secret: process.env.JWT_SECRET! }),
}),
});
Authentication in mekik is connection-time only — it decides who may open a socket, and implies no authorization or RBAC beyond that.
Human-in-the-loop
When a run needs an answer, mekik sends a text frame carrying actions. The connector maps it to Chativa's native quick-reply type, so the chips render without a custom component; tapping one sends the value back as the user's answer, which resumes the interrupted run:
{ "type": "text", "seq": 43, "from": "bot",
"data": { "text": "Deploy to production?" },
"actions": [{ "label": "Approve" }, { "label": "Cancel" }] }
The chips are kept visible after the tap (keepActions) so the transcript still reads as a record of what was asked and answered.
Tool calls
tool_call frames are a lifecycle, not a log: the same id is re-sent as its status advances, and Chativa upserts it in place.
{ "type": "tool_call", "seq": 45, "data": {
"id": "call-1", "name": "get_weather", "status": "running", "params": { "city": "Ankara" } } }
{ "type": "tool_call", "seq": 46, "data": {
"id": "call-1", "name": "get_weather", "status": "completed", "result": "18°C" } }
Skills
mekik servers can give their agents Agent Skills (mekik PROTOCOL.md §12). The connector implements the whole client side of that contract.
Server catalog. Right after welcome the server announces its skill catalog (level-1 summaries: name, description, tags). The connector caches it per URL and hands the hash back on reconnect (hello.skillsHash), so an unchanged catalog is never re-sent — the same ETag handshake as server-defined GenUI components:
connector.onSkills((skills) => renderSkillBadges(skills)); // replayed if it already arrived
connector.serverSkills; // the current level-1 summaries (empty before the first catalog)
Skill traces. When the agent loads a skill, a persistent skill frame travels with the transcript — like tool_call, the same data.id is re-sent on replay, so treat deliveries as upserts:
connector.onSkillUse((use) => {
// { id, name, status: "loaded" | "error", source?, error? }
});
Client-declared skills. The page can declare inline skills of its own — a house style, the names its screens use. Declarations travel in hello.skills and are re-sent on every reconnect; a client_skills frame replaces the set at runtime. The server side is off by default: a server that did not opt in (MekikOptions.clientSkills) ignores declarations entirely, and a client skill can never shadow a server skill.
const connector = new MekikConnector({
url: "wss://bot.example.com/chat",
skills: [{
name: "ui-conventions",
description: "How this app names its screens and actions.",
instructions: "# UI conventions\nUse the names from the sidebar.",
}],
});
Like tools, the set is sealed at construction: registerSkill / unregisterSkill require allowDynamicSkills: true, so injected script cannot rewrite what a server-side model will read.
Generative UI
genui frames stream an AIChunk per streamId, mounting a registered GenUI component inline as it arrives. Events fired by that component travel back as genui_event, which makes GenUI bidirectional over the same socket:
// → { "type": "genui_event", "streamId": "stream-1", "eventType": "submit", "payload": {...} }
Regenerate and edit
The connector implements regenerate() and editMessage(), but the widget only shows the Regenerate and Edit message actions when the server allows them. The server allows them in its welcome frame:
{ "type": "welcome", "data": { "conversationId": "…", "capabilities": { "regenerate": true, "edit": true } } }
The connector reports this through onCapabilities. Before the first welcome, and for any welcome without capabilities, both are off: a server that doesn't support the regenerate / edit frames would answer them with bad_request. Every welcome (including after a reconnect) replaces the previous report, so the buttons follow the server you are connected to.
| Action | Frame sent |
|---|---|
| Regenerate | { "type": "regenerate", "messageId": "<id of the clicked bot bubble>" } |
| Edit | { "type": "edit", "messageId": "<id of the user message>", "data": { "text": "…" } } |
Server support pending. The
capabilitiesfield and theregenerate/editframes are not yet part of mekik's PROTOCOL.md. Current mekik servers don't advertise them, so the buttons stay hidden until server support lands.
Offline queue
With queueOfflineMessages: true (the default) a send that happens while the socket is down is queued and flushed on the next connect. The promise resolves only when the payload actually reaches the wire, so the bubble stays on "sending" instead of being stamped "sent" for a message the server never received.
Behind Converger
Converger is a channel hub that can sit between Chativa and a mekik bot: it issues tokens, stores the transcript, replays by watermark and routes the same conversation to WhatsApp, Telegram, Slack, email or SMS. Its native WebSocket endpoint speaks a superset of mekik/1, so this connector works against it unchanged — nothing to install, no option to flip.
-
A token endpoint on your backend. Never ship the channel secret to the browser. A small route of yours calls
POST /api/v1/converger/tokens/generatewith the channel secret (Authorization: Bearer <secret>) and the signed-in user ({"user": {"id": "user-42"}}), and returns the token. A site without a backend can use the channel's publishable key instead: passnew PublishableKeyAuth({ baseUrl, publishableKey })from@chativa/connector-convergerasauth(needs AimTune/converger#195). -
The connector, pointed at
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: an expired or revoked token is replaced by a fresh one.auth: new TokenAuth({token: async () => (await (await fetch("/api/chat-token", { method: "POST" })).json()).token,}),conversationId, // from POST /api/v1/converger/conversations, or omit: welcome names a new onerouteInUrl: true, // conversationId in the query string, for a load balancer that routes by conversationresumeConversation: true,});Leave
protocolsempty: a client that offers no subprotocol gets Converger's mekik/1 profile — noack,deliveryStatusorpresenceframes (which this connector would render as empty bubbles), heartbeats as WebSocket pings, andtoken_expired/token_revokedreported asunauthorizedsoTokenAuthfetches a new token and reconnects with the same watermark.
What works over Converger today: the hello / welcome handshake (a token's user_id wins over userId), watermark resume, user turns (stored exactly once — the message id is used as Converger's clientId), bot and agent messages, quick-reply chips, rich message types, replayed tool_call / genui / interrupt frames, typing, auth rejection and refresh. regenerate / edit stay hidden (welcome reports them false). Live frames from a mekik bot behind the hub (run, genui_components, skills) and the client frames forwarded to it (resume, genui_event, client_tools, survey, …) need Converger's bot relay (AimTune/converger#64); until then the hub answers error bot_unavailable, which this connector ignores. Converger runs this connector against a real server in CI (chativa_compat_test.exs), so a hub change that breaks it fails Converger's build.
Converger's persistent event (reactions, edits) and conversationUpdate frames are still delivered on the mekik/1 profile and render with the fallback component — register renderers named event and conversationUpdate, or drop them with an onAfterReceive extension.
For receipts, uploads, history paging, reactions and the agent panel, use @chativa/connector-converger — the same class, extended. The sandbox has both as presets ("mekik behind Converger" and "Converger hub").
Capabilities
Implemented: sendMessage, onMessage, onConnect / onDisconnect, onTyping, onToolCall, onGenUIChunk, receiveComponentEvent, sendSurvey, regenerate / editMessage / onCapabilities (server-gated, see Regenerate and edit).
Not implemented: sendFile, loadHistory (watermark replay covers resume instead), onMessageStatus, sendFeedback, multi-conversation. Behind a Converger hub, @chativa/connector-converger adds all five.
React Native
The connector runs unmodified inside @chativa/rn-webview's WebView embedding — pass connector: { type: "mekik", options } with a JSON-safe auth spec ({ kind: "token", ... } or { kind: "cookie" }) instead of a live TokenAuth / CookieAuth instance. See React Native.