GenUI Model & Runtimes
The model in one paragraph
The brain emits a GenUI widget by writing a fenced block carrying a widgetName. The server converts that into a stream segment of type:"unisphere-tool", shaped { type, content, metadata:{ widgetName, runtimeName }, speechId?, threadId? }. All widgets share widgetName:"unisphere.widget.genie" — the host keys off metadata.runtimeName (stripping the -tool suffix) to pick a renderer.
The SDK turns each segment into a framework-agnostic descriptor {kind, data} that your app maps to DOM. Nothing here emits HTML. Every string and URL is run through core/safety.js first.
The first-class runtimes
Backend tool key (defined server-side) → wire runtimeName → normalized dispatch key (the renderer registry key). Source: src/core/stream.js GENUI_RUNTIMES; src/experience/genui/parse.js RUNTIMES (derived from GENUI_RUNTIMES, so the two can never drift).
| # | Backend key | Wire runtimeName |
Normalized | Purpose |
|---|---|---|---|---|
| 1 | flashcards |
flashcards-tool |
flashcards |
Study Q/A cards |
| 2 | followups |
followups-tool |
followups |
Suggested next-question chips |
| 3 | sources |
sources-tool |
sources |
RAG citation cards (with URLs) |
| 4 | summarization |
summary-tool |
summary |
Text/markdown summary and bullets |
| 5 | video_gallery |
video-gallery-tool |
video-gallery |
Gallery of Kaltura clips (by entryId) |
| 6 | show_link |
show-link-tool |
show-link |
A single link card |
| 7 | external_video |
external-video-tool |
external-video |
Embed a non-Kaltura video |
| 8 | user_properties_form |
user-properties-form-tool |
user-properties-form |
Structured data-collection form |
| 9 | gallery_slides |
content-gallery-tool |
content-gallery |
Gallery of content slides/cards (with images) |
normalizeRuntime(name) (parse.js) strips a trailing -tool and trims; it tolerates an already-normalized name and a non-string (→ ''). isKnownRuntime(name) tests membership in this set. Any other runtime (e.g. the backend's gen-ui-composer-tool, gen-ui-components-tool, kaltura-video-player-tool — see GenUI · Safety and Restrictions) is NOT in this set and falls through to a safe fallback.
How a widget reaches your screen (the data flow)
- Author — at intellect creation (the agent's configuration), enable the gating capability (see GenUI · Authoring and Consuming Widgets). The capability injects a template block into the system prompt telling the model when to emit that fenced widget.
- Emit — the brain writes a fenced block with
widgetName:"unisphere.widget.genie"andruntimeName. The server turns it into aunisphere-toolsegment. - Stream — segments arrive as
agent_raw_textdeltas (live socket) or SSE/NDJSON lines (HTTP converse). A single widget can span multiple fragments. - Assemble —
SegmentAssembler(genui/segments.js) buffers fragments and flushes a complete widget on a boundary change (differentruntimeorspeechId, or turn end). If that boundary change interrupts a JSON body before it finishes writing, the fragment is flagged malformed (onMalformed) instead of flushed as a widget.ExperienceRendererthen mounts the same typed{kind:'error', data:{runtime, message}}fallback it uses for a throwing custom renderer. - Parse —
parseWidget(segment)(parse.js) →{widgetName, runtimeName, runtime, model}.parseContentis forgiving and never throws: object content is used as-is; string content is JSON-parsed first, else parsed as a loosekey: valueblock, else preserved under.raw. - Render —
ExperienceRenderer._renderWidgetdispatchesmodelto the runtime's renderer →{kind, data, runtime, runtimeName, _meta}. Yourmount(descriptor)turns it into DOM.
Two delivery paths (this is the #1 gotcha)
- HTTP converse (
Management.conversations.stream/send) — the reliable widget path. You passforce_experienceand read widgets off the segment stream.collectConverse()(core/stream.js) separates them:experiences(keyed by wireruntimeName),experiencesList(arrival order), andkindCounts.experience.segmentKind(seg)classifies aunisphere-toolsegment as'experience'(vs'spoken'fortext/avatar/avatar-filler). - Live avatar socket (
KalturaAvatarSession) — the join payload hardcodesforce_experience:'avatar_only'(src/experience/wire.js,EXPERIENCESjoin). So the socket emits structured widgets rarely.ExperienceRenderer.start()subscribes tobrainSegmentand tolerates zero widgets. Don't rely on rich widgets on the live face path. Drive visuals yourself via the client-command channel, or read widgets from the HTTP path.
force_experience — a hint, not a contract
- Valid values (single source of truth,
src/experience/wire.jsEXPERIENCES):'markdown','summarization','flashcards','avatar_only'. - Parameters are validated on the first iteration (entering
for await) inconversations.stream(conversations.js), NOT at call time — an invalid value throws a typedvalidation_error. - It is a HINT: the brain decides which widget(s) to actually emit, based on the prompt and the intellect. Asking for
flashcardsmay yieldflashcards-toolandfollowups-tool, or neither. The renderer renders whateverruntimeNamearrives. Tests are lenient by design.
Related docs
| Doc | Covers |
|---|---|
| GenUI · Per-Runtime Widget Detail | Per-runtime model keys, constraints, and descriptor shapes |
| GenUI · Authoring and Consuming Widgets | Capability gating and ExperienceRenderer/mountWidget consumption |
| GenUI Reference | Back to the index |