Skip to main content

Frames

Every message on a mekik socket is a frame: a flat JSON object with a type discriminator. This page is the catalogue. Shapes here match PROTOCOL.md §3 and the golden fixtures — the fixtures are authoritative.

Frames are flat (no nested envelope). Persistent server→client frames carry a seq; some carry a timestamp (ms since epoch). Transient frames carry neither.

Client → server

typeshapemeaning
hello{type, userId?, conversationId?, watermark?, token?, meta?, componentsHash?}Handshake. May instead travel as the WS query string. meta is a client-supplied context map. componentsHash is the component catalog the client has cached — a matching hash means the catalog is not re-sent. All fields optional.
text{type, data:{text}, meta?}One user turn → starts a run (or is refused busy / interrupted).
resume{type, answers:{[interruptId]: any}}Answer the open interrupts, keyed by thread-scoped interrupt id. Must cover every open interrupt.
genui_event{type, streamId, eventType, scope?, component?, payload}An interaction from a mounted GenUI component. scope comes from the markup — "component" (component-event), "graph" (mekik-event), or absent (data-event) — and decides who receives it: the node parked on onEvent, the app's handler, or whichever answers first. A submit naming an open interrupt is coerced to a resume regardless. See Bidirectional events.
abort{type}Cancel the in-flight run at the next superstep boundary. The last checkpoint stands.

A malformed inbound frame (bad JSON, missing type) draws error{code:"bad_request"} and is otherwise ignored — the connection stays open.

hello

Sent first on a new socket. Identity may be asserted here or omitted for an anonymous connect:

{ "type": "hello", "userId": "u-1", "conversationId": "conv-9", "watermark": 12,
"token": "eyJhbGc…", "meta": { "locale": "tr" } }

The transport also accepts these as query-string params (?userId=…&conversationId=…&watermark=…&token=…) and merges them; the hello frame wins on conflict. See Transport.

text

One user turn:

{ "type": "text", "data": { "text": "ORD-42" } }

resume

Answer open interrupts, keyed by the interrupt id the interrupt frame carried:

{ "type": "resume", "answers": { "gate:interrupt#0": { "approved": true } } }

Answering by id (not ilmek's key) and covering every open interrupt are both required — see Human-in-the-loop.

Server → client

typepersistentshape
welcomeno{type, data:{protocol, conversationId, userId, connectionId, watermark, pending: PendingView[]}}
textyes{type, id, seq, from:"bot"|"user", data:{text}, timestamp}
tool_callyes{type, seq, data:{id, name, status:"running"|"completed"|"error", params?, result?, error?}}
genuiyes{type, seq, streamId, done, chunk: AIChunk}
interruptyes{type, seq, id, data:{payload, ui?, actions?}}
interrupt_resolvedyes{type, seq, id, data:{answer?}}
rich messageyes{type: <rendererName>, id, seq, from:"bot"|"user", data, timestamp}
genui_componentsno{type, hash, unchanged?, components?: ComponentDefinition[]}
runno{type, data:{status:"started"|"finished"|"interrupted"|"error"|"aborted"}}
errorno{type, data:{code, message}}

Persistent frames

These carry seq and are the durable transcript — exactly what reconnect replays.

text — a chat bubble. from is "bot" (the run's reply) or "user" (the fan-out/replay copy of a user's own turn to their other connections):

{ "type": "text", "id": "msg-1", "seq": 13, "from": "bot",
"data": { "text": "Refund complete: ORD-42" }, "timestamp": 1750000000000 }

tool_call — a tool lifecycle, upserted by data.id. The same id is re-sent as status advances; the client updates the existing entry rather than adding a new one:

{ "type": "tool_call", "seq": 6, "data": {
"id": "call-1", "name": "get_order", "status": "running", "params": { "id": "ORD-42" } } }
{ "type": "tool_call", "seq": 7, "data": {
"id": "call-1", "name": "get_order", "status": "completed", "result": { "total": 249.9 } } }

genui — one AIChunk under a turn streamId. done:false while the stream is open; the mapper closes it at run end with a stream_done event chunk (done:true):

{ "type": "genui", "seq": 8, "streamId": "stream-1", "done": false,
"chunk": { "type": "ui", "component": "order-card", "props": { "id": "ORD-42" }, "id": 0 } }

interrupt — a human-in-the-loop pause. id is the thread-scoped interrupt id a resume answers; data.payload is the question; ui and actions are optional presentation:

{ "type": "interrupt", "seq": 9, "id": "gate:interrupt#0", "data": {
"payload": { "title": "Refund $249.9 for ORD-42?" },
"ui": { "component": "approval-form", "props": { "orderId": "ORD-42" } },
"actions": [ { "label": "Approve", "value": { "approved": true } },
{ "label": "Reject", "value": { "approved": false } } ] } }

interrupt_resolved — acknowledges an answered pause so every tab and future replay learns it's closed:

{ "type": "interrupt_resolved", "seq": 10, "id": "gate:interrupt#0",
"data": { "answer": { "approved": true } } }

Rich message — the text envelope under a client message-renderer name, with that renderer's payload as data. This is the one open entry in the persistent list: the type is any name the client registered ("image", "card", "carousel", …), never one of the protocol's own frame types:

{ "type": "image", "id": "msg-7", "seq": 12, "from": "bot",
"data": { "src": "https://…/receipt.png", "caption": "Your receipt" },
"timestamp": 1750000000000 }

A client with no renderer for the type ignores the frame — the standard unknown-frame rule. Authoring guide: Rich messages.

Transient frames

Live-only. Never stored, never replayed.

welcome — sent on every connect, before any replay:

{ "type": "welcome", "data": {
"protocol": "mekik/1", "conversationId": "conv-9", "userId": "u-1",
"connectionId": "connection-abc", "watermark": 12, "pending": [] } }

pending is a PendingView[] re-announcing open interrupts so a reconnecting UI can re-render approval forms. A PendingView is {id, data:{payload, ui?, actions?}} — an interrupt frame minus seq/timestamp.

genui_components — the components this server defines itself, sent once per connection right after welcome (before the replay tail, so a widget named by the first replayed chunk is already registered):

{ "type": "genui_components", "hash": "d8a1c300…",
"components": [{ "name": "delivery-card",
"template": "<h3>{{title}}</h3>{{#each lines}}<p>{{this.label}}</p>{{/each}}",
"css": ".card { padding: 12px; }",
"props": { "title": "", "lines": [] } }] }

The client registers each definition under its name and mounts it from an ordinary ui chunk from then on — no client build step. hash versions the catalog: the client stores it, hands it back in hello, and a matching hash gets { "type": "genui_components", "hash": "…", "unchanged": true } with no markup instead. A server that defines no components sends no frame at all. Definitions, the template language and the interaction attributes: Components the server defines.

run — the turn's lifecycle signal; always the last frame of its run:

{ "type": "run", "data": { "status": "interrupted" } }

Statuses: started, finished, interrupted, error, aborted. See Engine → terminal states.

error — a coded, non-fatal error to one sender (the socket stays open unless it's an auth reject):

{ "type": "error", "data": { "code": "interrupted", "message": "answer the open interrupt(s) first" } }

Error codes

codeCauseSocket
busya text arrived while a run is in flightstays open
interrupteda text (not resume) arrived while the thread is parkedstays open
incomplete_resumea resume omitted an open interruptstays open
bad_requestmalformed inbound framestays open
unauthorizedthe authenticator rejected the connectioncloses with WS code 4401
internala handler threwstays open if it can

Shared payload types

AIChunk (the genui payload; identical to chativa's):

type AIChunk =
| { type: "ui"; component: string; props?: Record<string, unknown>; id?: string | number }
| { type: "text"; content: string; id?: string | number }
| { type: "event"; name: string; payload?: unknown; id?: string | number };

MessageAction (an interrupt/quick-reply chip):

type MessageAction = { label: string; value?: unknown };
// value omitted → the answer is the label string

Where to go next