Skip to content

feat(genui): server-defined components, and buttons that say who they're for - #35

Merged
AimTune merged 2 commits into
mainfrom
feat/genui-component-event-routing
Jul 30, 2026
Merged

feat(genui): server-defined components, and buttons that say who they're for#35
AimTune merged 2 commits into
mainfrom
feat/genui-component-event-routing

Conversation

@AimTune

@AimTune AimTune commented Jul 30, 2026

Copy link
Copy Markdown
Owner

The client half of mekik PROTOCOL.md §10. Backend counterpart: AimTune/mekik#10.

Server-defined components. The backend announces a catalog on connect and @chativa/genui turns each definition into a custom element, so a { type: "ui", component: "order-card" } chunk mounts a widget nobody compiled into the page. Cached in localStorage under the server URL and validated by a hash riding along on the hello frame the connector already sends — so a returning user's widgets render before the server answers, at no extra round trip.

What its buttons do. <chativa-html> bound exactly one attribute, data-event, and the backend had to guess who the click was for. Now the attribute is the answer:

attribute scope sent meaning
component-event="rate_delivery" "component" the widget's own conversation with the graph node that mounted it
mekik-event="track_order" "graph" the application, which may start a new turn
data-event="…" none let the backend decide (the original form)

Most specific wins, an empty value is not a trigger, and forms carry the scope too.

Threading it through

scope and the originating component reach the backend as optional fields on the genui_event frame, carried as one optional GenUIEventOptions argument:

GenUIHtmlElement → sendEvent(type, payload, { scope })
GenUIMessage     → stamps `component` from the chunk
ChatWidget / AgentPanel → ChatEngine.receiveComponentEvent(…, opts)
IConnector       → receiveComponentEvent(streamId, eventType, payload, opts?)
createGenUIEventFrame → { type, streamId, eventType, payload, scope?, component? }

Optional at every hop on purpose. A data-event still makes the two-argument sendEvent call it always made, and a frame with no routing metadata is byte-for-byte the frame backends see today. All five connectors pass opts through; ones whose protocol doesn't model routing simply never receive it.

The component name is stamped by GenUIMessage, not by the component — only the chunk knows which widget an interaction came from, and asking components to report it themselves is asking for it to be wrong.

MekikConnector

An interrupt frame carrying data.event now renders nothing. That pause is waiting for a widget already on screen, so default Approve/Cancel chips would be a dead end: tapping Approve sends a resume the node isn't waiting for.

Verified against the real backend

Not by inspection — I fed chativa's createGenUIEventFrame output straight into a live mekik app:

interrupt data: {"payload":{},"event":"rate_delivery"}
chativa frame:  {"type":"genui_event","streamId":"…","eventType":"rate_delivery",
                 "payload":{"stars":5},"scope":"component","component":"delivery-card"}
  ✓ a component-event frame resolves the node parked on onEvent
  ✓ …and never reaches the graph handler
  ✓ a mekik-event frame reaches the graph handler, carrying the component name
  ✓ a plain data-event produces no scope key, and still reaches the handler
  ✓ an unknown scope is rejected as bad_request

Plus 570 tests passing and 13 packages typechecking.

Reviewer notes

  • GenUIComponentAPI.sendEvent gained an optional third parameter — source-compatible for every existing component.
  • One existing ChatEngine test was updated: it asserted receiveComponentEvent arity, which changed by one optional argument. A new test covers the forwarding.
  • The new attributes need the mekik side to interpret scope; that's feat(genui): the backend defines the widget, and its buttons route mekik#10.

🤖 Generated with Claude Code

AimTune and others added 2 commits July 30, 2026 13:08
…'re for

Two halves of mekik PROTOCOL.md §10, on the client side.

Server-defined components: the backend announces a catalog on connect and
`@chativa/genui` turns each definition into a custom element, so a
`{ type: "ui", component: "order-card" }` chunk mounts a widget nobody compiled
into the page. The catalog is cached in localStorage under the server URL and
validated by a hash riding along on the `hello` frame, so a returning user's
widgets render before the server answers.

The other half is what a button on one of those widgets does. `<chativa-html>`
bound exactly one attribute — `data-event` — and the backend had to guess who the
click was for. Now the attribute is the answer:

  component-event="<name>"  → scope "component"  the widget's own conversation
                                                 with the node that mounted it
  mekik-event="<name>"      → scope "graph"      the application
  data-event="<name>"       → no scope           the backend decides

Most specific wins; an empty value is not a trigger; forms carry the scope too.

`scope` and the originating `component` ride to the backend as optional fields on
the `genui_event` frame, threaded as one optional `GenUIEventOptions` argument
from `sendEvent` through `GenUIMessage`, `ChatWidget`/`AgentPanel`, `ChatEngine`
and `IConnector` to `createGenUIEventFrame`. Optional at every hop on purpose:
`data-event` still makes the two-argument `sendEvent` call it always made, and a
frame with no routing metadata is byte-for-byte the frame backends see today.

The component name is stamped by `GenUIMessage`, not by the component — only the
chunk knows which widget an interaction came from, and asking components to
report it themselves is asking for it to be wrong.

MekikConnector: an `interrupt` frame carrying `data.event` renders nothing. That
pause is waiting for a widget already on screen, so default Approve/Cancel chips
would be a dead end — tapping Approve would send a `resume` the node is not
waiting for.

Verified against the real backend, not by inspection: chativa's
`createGenUIEventFrame` output fed straight into a live mekik app resolves the
node parked on `onEvent` for a component-event, reaches the graph handler with
the component name for a mekik-event, and is rejected as `bad_request` for an
unknown scope. 570 tests pass; 13 packages typecheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ilds

`website/docs/genui/streaming.md` opened with `---` and never closed the block,
so docusaurus parsed the whole page as YAML front matter and the build failed
outright ("end of the stream or a document separator is expected"). The page also
had none of the metadata its siblings carry, so it would have sorted and titled
itself by filename.

Verified with `docusaurus build` on both sites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AimTune
AimTune merged commit 316d88f into main Jul 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant