Skip to main content

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.

FieldDefaultDescription
urlRequired. mekik WebSocket endpoint, e.g. wss://bot.example.com/chat.
protocols[]Sub-protocol(s) passed to the WebSocket constructor.
reconnecttrueAuto-reconnect when the socket drops. Suppressed after an auth rejection.
reconnectDelay2000Milliseconds between reconnect attempts.
maxReconnectAttempts5Give up after this many consecutive failures. Reset on a successful handshake.
queueOfflineMessagestrueHold outgoing messages while the socket is down and flush them on (re)connect.
userIdserver-mintedStable user identity. Omit to let the server generate one (announced via welcome).
conversationIdserver-mintedConversation to resume. Omit to start a new one.
resumeConversationfalsePersist identity + watermark in localStorage and resume across page reloads.
authHow this client authenticates — a MekikAuthProvider adapter (CookieAuth, TokenAuth, or your own). Omit for servers that don't authenticate. See Authentication.
tokenDeprecated — shorthand for auth: new TokenAuth({ token, maxRetries: 0 }). Still works.
onAuthErrorCalled 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 frameDirectionBecomes
helloclient → serverSent on open — userId / conversationId / watermark / token. All fields optional.
welcomeserver → clientIdentity + current watermark. Captured on connector.identity, never rendered as a message.
text (bot)server → clienttext message bubble.
text + actionsserver → clientquick-reply message — chips render natively; tapping one answers the bot.
text (from: "user")server → clientTranscript replay / other-tab fan-out of the user's own messages.
tool_callserver → clientonToolCall — same id upserted runningcompleted / error.
run { status }server → clientTyping indicator: started → on, finished → off.
genuiserver → clientonGenUIChunk — mounts a GenUI component inline.
genui_eventclient → serverSent when a mounted GenUI component fires an event (form submit, card action…).
errorserver → clientAuth rejection — surfaced via onAuthError, followed by close code 4401.
surveyclient → serversendSurvey() payload.

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
CookieAuthCookieAuthenticatorThe browser's cookie — nothing to send
TokenAuth (string)StaticTokenAuthenticatorA long-lived API key
TokenAuth (function)HmacJwtAuthenticatorA short-lived JWT, re-minted per attempt
your own MekikAuthProvideryour own Authenticatoranything

The provider is consulted before every socket, never once and cached — which is what lets a reconnect carry a credential that's still valid.

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

transportCredential rides inUse when
"hello" (default)The hello frame's tokenAlmost always.
"query"?token= on the socket URLSomething 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:

  • TokenAuth with 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.
  • CookieAuth retries once if you gave it a refresh, 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" } }

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:

connector.receiveComponentEvent("stream-1", "submit", { email: "[email protected]" });
// → { "type": "genui_event", "streamId": "stream-1", "eventType": "submit", "payload": {...} }

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.

Capabilities

Implemented: sendMessage, onMessage, onConnect / onDisconnect, onTyping, onToolCall, onGenUIChunk, receiveComponentEvent, sendSurvey.

Not implemented: sendFile, loadHistory (watermark replay covers resume instead), onMessageStatus, sendFeedback, multi-conversation.

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.