GenUI — Complete Capabilities Reference
Everything an agent can put on screen beyond spoken text: the unisphere-tool
runtimes (flashcards, summaries, sources, forms, Kaltura/external video, images, links)
the Genie brain backend emits as type:"unisphere-tool" stream segments, and the SDK renders
natively via ExperienceRenderer.
This is the authoritative map — every runtime, its enabling capability, the exact wire shape, the SDK function/keys that parse and render it, the backend code flow it rides, and the restrictions that bite in practice.
All claims here are anchored to repo source (src/...) and WIRE-PROTOCOL.md; where a
behavior is inferred rather than live-captured, it is marked INFERRED.
Naming note.
unisphere-toolandunisphere.widget.geniebelow are the Genie brain's literal, on-the-wire constant values — carried over from a naming decision made outside this SDK, and preserved verbatim here because changing them would break real interoperability. They are unrelated to "GenUI," this doc's own name for the feature; don't read them as a reference to a different product.
The model in one paragraph
The brain emits a GenUI widget by writing a fenced block carrying a widgetName. The Genie
brain backend's message_service 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} your app maps to DOM. Nothing here
emits HTML; every string/URL is run through core/safety.js first.
The first-class runtimes
Backend tool key (defined in the Genie brain backend's experience-definitions module) → 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 + 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 lines 54–58) 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 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, enable the gating capability (table below). The capability injects a Jinja 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"+runtimeName.message_serviceturns 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 —ExperienceRenderermounts 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: object content is used as-is; a string is JSON-parsed, else parsed as a loosekey: valueblock, else preserved under.raw. Never throws. - 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.jsline 17;EXPERIENCES):'markdown','summarization','flashcards','avatar_only'. - Parameters are validated on the first iteration (entering
for await) inconversations.stream(conversations.jslines 120–121), NOT at call time — an invalid value throws a typedvalidation_error. - It is a HINT: the brain decides which widget(s) to actually emit from the prompt + intellect.
Asking for
flashcardsmay yieldflashcards-toolandfollowups-tool, or neither. The renderer renders whateverruntimeNamearrives; tests are lenient by design.
Per-runtime detail (model keys → descriptor)
Each renderer lives in src/experience/genui/renderers/<name>.js, takes (model, ctx), and
returns {kind, data}. ctx.urlPolicy ({allow:[schemes]}) is threaded from the
ExperienceRenderer. Every renderer accepts multiple input key aliases (the model is untrusted
LLM output) and clamps text via safeText(str, max) and URLs via safeUrl(url, policy).
1. flashcards (renderFlashcards)
Cards come from model.cards, model.items, or model.flashcards. Each card:
| Field | Source keys (model) | Constraint |
|---|---|---|
front |
front, question, term |
≤1000 chars |
back |
back, answer, definition |
≤4000 chars |
title |
title |
≤300 chars |
label |
label, front |
≤120 chars — the flip toggle's accessible name |
Descriptor: {kind:'flashcards', data:{title, cards:[{front, back, label}]}}.
2. followups (renderFollowups)
| Field | Source keys (model) | Constraint |
|---|---|---|
questions |
questions, followups, items — each item a string or {text|question} |
≤500 chars per item; empty items filtered |
Descriptor: {kind:'followups', data:{questions:[string]}}. Server-side add_to_history:false —
chips are suggestions, not replayed into history.
3. sources (renderSources)
Items come from model.sources, model.items, or model.citations. Each item:
| Field | Source keys (model) | Constraint |
|---|---|---|
title |
title, name, label |
≤500 chars |
url |
url, link, href (via safeUrl) |
unsafe scheme → '' |
snippet |
snippet, text, content |
≤2000 chars |
score |
score, relevance, similarity |
forward-compatible passthrough — omitted when absent/non-numeric, never 0 |
Descriptor: {kind:'sources', data:{sources:[{title, url, snippet, score?}]}}. RAG-driven emission
is unverified, so score's presence is NOT a claimed backend guarantee.
4. summary (renderSummary)
| Field | Source keys (model) | Constraint |
|---|---|---|
summary |
summary, text, content, raw |
≤8000 chars; via safeSource — preserves \n/\r/\t so markdown structure survives |
bullets |
bullets, points, items |
≤1000 chars per item |
title |
title |
≤300 chars |
Descriptor: {kind:'summary', data:{title, summary, bullets:[string]}}. The summary stays
untrusted (LLM output); by default mountWidget renders it as flat escaped text. Pass
mountWidget(descriptor, el, {markdown:true}) to opt into a first-party, allow-listed
markdown-to-DOM renderer instead — see "Markdown rendering" below. The SDK never emits raw HTML
either way.
5. video-gallery (renderVideoGallery) — Kaltura clips
Items come from model.videos, model.entries, or model.items. Each item:
| Field | Source keys (model) | Constraint |
|---|---|---|
entryId |
entryId, entry_id, id |
≤100 chars, preserved verbatim — host plays via the Kaltura player |
title |
title, name |
— |
thumbnailUrl |
thumbnailUrl, thumbnail, thumb |
via safeUrl |
url |
url, playUrl, link |
via safeUrl |
embedUrl |
embedUrl, embed_url, embedLink |
via safeUrl |
duration |
duration, length |
string-kept, ≤40 chars, to tolerate "1:23" or a seconds count |
description |
description |
≤2000 chars |
alt |
alt, title |
≤300 chars — the image's accessible name |
Descriptor: {kind:'video-gallery', data:{title, videos:[{entryId, title, thumbnailUrl, url, embedUrl, duration, description, alt}]}}.
This is the in-platform video widget: the host renders the Kaltura player against entryId.
6. show-link (renderShowLink) — links
| Field | Source keys (model) | Constraint |
|---|---|---|
url |
url, linkUrl, link, href, mediaUrl |
via safeUrl |
label |
label, linkText, title, text |
≤300 chars; falls back to the URL |
description |
description |
≤2000 chars |
Descriptor: {kind:'show-link', data:{url, label, description, safe}} where safe:!!url — an
unsafe scheme yields url:'' + safe:false so the host drops it (mirrors the earnings app's
renderSafeLink null-drop).
7. external-video (renderExternalVideo) — non-Kaltura video embeds
| Field | Source keys (model) | Constraint |
|---|---|---|
url |
url, videoUrl, mediaUrl, src, embedUrl |
requires an ABSOLUTE http(s) URL — a non-https?:// value (relative path, //host, mailto) yields url:''; this is an iframe/<video src> surface |
provider |
provider, source |
≤100 chars |
poster |
poster, thumbnail, thumbnailUrl |
via safeUrl — a still to show before play |
description |
description |
≤2000 chars |
Descriptor: {kind:'external-video', data:{url, title, provider, poster, description, safe}},
safe:!!url. The client check is defense-in-depth; the server-side media-URL validator is the
primary guard (INFERRED — server validator not in this repo).
8. user-properties-form (renderUserPropertiesForm) — structured data collection
Fields come from model.fields, model.properties, or model.items. A field without a key is
dropped. Each field:
| Field | Source keys (model) | Constraint |
|---|---|---|
key |
key, name |
— |
type |
type (lowercased) |
validated against {str,int,float,bool,list,dict,email,phone,text}; unknown → 'str' |
label |
label, prompt, key |
— |
knownValue |
knownValue, known_value |
a value the model already extracted, for pre-fill |
required |
required |
true only when required === true |
description |
description, help |
≤500 chars |
required/description let a host wire aria-required/aria-describedby/inputmode.
Descriptor: {kind:'user-properties-form', data:{title, fields:[{key, type, label, knownValue, required, description}]}}.
- Report back: the default (
user_properties_forms-configured) path has the host callsession.submitStructuredDataForm(info)(session.js), whichsanitizeJsons the object and emits the socket eventsetFormLeadInfo— a fire-and-forget emit with no durable server-side read-back. An app can instead take a different, durable path: never configureuser_properties_formsat all, and reach this sameuser-properties-formwidget purely as one enum value of its ownshow_widgetclient tool (kaltura_genie_experiencesOFF), rendering the widget into its own dedicated host UI and, on submit, bridging the collected values intorequest_varsso the brain itself can call a server-side api tool that persists them wherever you point it — see STRUCTURED-DATA-FORMS.md. - For the full picture — configuration, the exact mandatory prompt injection, where
setFormLeadInfoactually persists server-side, and how to deliver the collected data somewhere durable — see STRUCTURED-DATA-FORMS.md and EXTERNAL-API-INTEGRATIONS.md.
9. content-gallery (renderContentGallery) — image/content cards
Items come from model.items, model.slides, or model.cards. Each item:
| Field | Source keys (model) | Constraint |
|---|---|---|
id |
id, slideId, key |
≤100 chars, addressable — slides are ordered, mirrors entryId |
title |
title, name, heading |
— |
description |
description, text, body |
≤2000 chars |
imageUrl |
imageUrl, image, thumbnail |
via safeUrl |
url |
url, link, href |
via safeUrl |
alt |
alt, title |
≤300 chars — the image's accessible name |
Descriptor: {kind:'content-gallery', data:{title, items:[{id, title, description, imageUrl, url, alt}]}}.
This is the image-bearing widget (a deck/gallery of cards with thumbnails). Note the backend
key is gallery_slides, and the video_gallery capability summary says it permits both
video-gallery-tool and content-gallery-tool.
- Multi-item only. The renderer always wraps
itemsin a CSS grid sized for several thumbnails (.kgenui__gallery,repeat(auto-fill, minmax(120px,1fr))) — it does not branch on item count, so a single, image-less item stretches to the grid's full row width inside the widget's full-slot frame and reads as an oversized, awkward card. A:has(> .kgenui__gallery > li:only-child)CSS rule can give that case a flex/centered treatment instead, and ashow_widgettool description can steer the brain towardsummaryfor a single text-only point. Prefercontent-galleryfor 2+ image-bearing items; usesummaryfor one.
Authoring — which capability turns each widget on
Capabilities are set at intellect creation (partner config caches ~24h; set them up front).
Source of truth: src/management/capabilities.js (CAPABILITY_INFO, CAPABILITY_DEFAULTS,
OFF_BY_DEFAULT). kind is tool | segment | mode | prompt — this only names the
mechanism that gates the capability on/off, not whether its content is persona-steerable.
avatar_filler (kind: 'prompt') is the exception to watch for: its filler phrasing is
server-generated per turn and NOT reliably steerable via base_directive, even though it
streams as a "spoken" segment alongside avatar/text (see
WIRE-PROTOCOL.md § 4e) —
disable the capability if the default phrasing doesn't fit your persona.
| Capability | Default | Kind | Gates runtime | Notes |
|---|---|---|---|---|
kaltura_genie_experiences |
ON | mode | (master) | Master switch for structured GenUI. Leaving it on injects a competing instruction that out-competes a custom tool — turn it OFF for command-only agents; see EXTERNAL-API-INTEGRATIONS.md § Don't skip kaltura_genie_experiences: 'off' |
generate_followup_questions |
ON | segment | followups |
— |
include_sources |
ON | segment | sources |
Pairs with use_knowledge_base (RAG) |
video_gallery |
OFF | segment | video-gallery (+ content-gallery) |
— |
external_video |
OFF | segment | external-video |
— |
show_link |
OFF | segment | show-link |
— |
avatar_show_content |
OFF | prompt | (avatar visual push) | — |
use_knowledge_base |
ON | tool | (feeds sources) |
async_search_knowledge_base RAG |
The structured data-collection form (user-properties-form) is configured via the intellect's
user_properties_forms (a LIST of {call_stage, properties:[{key,type}]} forms — the server rejects a bare object with 422), not a boolean
capability. The eight OFF_BY_DEFAULT capabilities are: avatar, avatar_filler,
avatar_show_content, video_gallery, external_video, show_link, use_web_search,
screen_share_analysis.
Consuming widgets in your app
The 2-line happy path — pass a DOM Element as mount and the SDK renders for you, live:
import { ExperienceRenderer } from '@kaltura/intelligent-agents/experience/genui';
new ExperienceRenderer({ session, mount: document.getElementById('widgets'), onAction }).start();
mountWidget (genui/renderers/mount.js, exported from ./experience/genui) is the SDK's last-mile
descriptor→DOM renderer — zero-dep, isomorphic (returns null with no DOM), never innerHTML,
accessible by construction, and ships zero styling (it emits the kgenui / kgenui__* class
contract for you to theme). Call it directly — mountWidget(descriptor, targetEl, { replace?, onAction? })
— or let ExperienceRenderer call it when mount is an Element (or target).
ExperienceRenderer options
new ExperienceRenderer({ session?, mount, target?, onAction?, renderers?, replace?, onUnhandled?, urlPolicy?, partnerId?, uiConfId?, clearOnTurnStart? }) (genui/renderer.js). uiConfId (string or
number) enables video-gallery to build a player-embed iframe URL (requires partnerId). mount is a
(descriptor)=>void function (full control) or a DOM Element (auto-rendered via mountWidget).
Live vs. headless dispatch
In the live socket runtime, .start() subscribes to session.on('brainSegment') and flushes on
turnEnd/avatarStopTalking, resetting on interrupted. clearOnTurnStart (default true) also
resets the assembler and clear()s rendered/last on the session's turnStart event (re-emitted
from the raw agent_start_speech socket event), so a widget from turn N never lingers into turn
N+1 — mirrors the Genie brain backend's own web client nulling content on AgentStartSpeechReceived.
Set false for intentional cross-turn persistence.
Headless, call .render(runtimeName, widget) (or .render(segment)) per segment from a
conversations.stream() feed — the reliable path.
Registration, fallback, and provenance
.register(runtimeName, fn)adds/overrides a renderer (name normalized);.has,.runtimes,.last,.rendered,.clear().replace:truekeeps only the latest widget per turn.- An unknown runtime renders
{kind:'unknown', data:{runtime, model}}and firesonUnhandled(never throws). A throwing custom renderer degrades to{kind:'error', ...}. _metareceipt stamps{partnerId, source:'experience/genui', scope:'conversation (geniegpcid, entitlement ON)', known, firstClass}—known= this instance has a renderer (a registered 10th runtime isknown:true);firstClass= one of the built-in set.
onAction, WIDGET_KINDS, and the hand-rolled escape hatch
onAction(action, payload) surfaces interactions mountWidget can't fulfil itself: 'followup'
{question} (→ session.speak), 'submit' {values} (→ session.submitStructuredDataForm), 'play'
{entryId,url,embedUrl}, 'open' {url}.
WIDGET_KINDS (exported) is the frozen list of every kind (the nine first-class GenUI runtimes +
unknown + error) — use it for an exhaustive host switch or a parity test.
You can still read seg.metadata.runtimeName and build DOM yourself (use
renderSafeLink/safeText/safeUrl so nothing un-sanitized hits the DOM). Prefer mountWidget —
the hand-rolled path is for a fully custom design system only.
Markdown rendering (opt-in)
mountWidget(descriptor, el, {markdown:true}) parses a summary widget's text
(genui/renderers/markdown.js, renderMarkdown) as markdown instead of flat text — headings
(#-######), bold/bold, italic/italic, inline `code`, [link](url), unordered/
ordered lists, fenced code blocks (with a language-<token> class), and GFM tables. A markdown
table renders through the tableEl builder (genui/renderers/dom-helpers.js) — one shared,
safe <table> construction path, no duplicate table logic. This is
markdown-in-plain-text rendering, not a new wire segment type: the underlying descriptor is
still {kind:'summary', data:{summary, ...}}; only the render path changes. It is never innerHTML — every text run is built via textContent/createTextNode (so a raw <script> tag in
the LLM output is inert text, not markup) and every extracted URL goes through safeUrl (a
javascript:/unsafe-scheme link degrades to plain safe text, never a dead/unsafe href). Default
behavior (no opts.markdown) is unchanged — flat escaped text — so no existing app regresses by
upgrading.
Theming + a11y contract (kgenui classes)
mountWidget emits semantic, accessible DOM and these stable classes (theme them in your CSS — the
SDK ships none): root kgenui kgenui--{kind} (role="group", aria-label); kgenui__title,
kgenui__text, kgenui__list, kgenui__chip, kgenui__card, kgenui__flip, kgenui__back,
kgenui__link, kgenui__img, kgenui__gallery, kgenui__form, kgenui__field, kgenui__label,
kgenui__input, kgenui__help, kgenui__submit, kgenui__sr-only. Built-in a11y: flashcards are
<button aria-pressed> flip toggles; followups are <button> chips in a labeled list; links carry a
visually-hidden "(opens in a new tab)" cue + rel=noopener noreferrer and are dropped when
safe:false; images always have alt; form fields are real <label for>+<input> with
type/inputmode from the field type and aria-required/aria-describedby from required/description.
The SDK ships zero CSS for these class names — style the .kgenui__* block in your own stylesheet.
Safety model (OWASP LLM05 — every widget passes through this)
src/core/safety.js:
safeText(s, max=2000)— coerces to string, strips ASCII control chars (the C0 rangeU+0000-U+001FplusU+007FDEL), length-clamps.safeUrl(url, {allow})— returns the URL only if its scheme is allow-listed (defaulthttps|http|mailto|tel); blocksjavascript:/data:/vbscript:/unknown. A scheme-relative path (/foo,foo/bar) is allowed, but an authority-relative URL (//host,\\host) is rejected (open-redirect / embed-hijack vector).external-videoadditionally requires an absolute http(s) URL.renderSafeLink(info, opts)— builds a real<a>viacreateElement+textContent+ scheme-checkedhref(neverinnerHTML);target=_blank+rel=noopener noreferrer; returnsnulloutside a browser or for an unsafe URL.sanitizeJson(v)— drops__proto__/constructor/prototype(used bysubmitStructuredDataForm+setDynamicPrompt).clampInbound(s)— inbound text clamp.- No renderer emits HTML; the host renders the
{kind, data}descriptor withtextContent/ DOM APIs. Asummary's markdown is the host's responsibility to sanitize.
Restrictions & gotchas (read before you build)
- Live socket rarely emits widgets —
force_experience:'avatar_only'is hardcoded on the avatar join (wire.js). Use the HTTP converse path for reliable widgets. force_experienceis a hint — never assume the requested experience arrived; the renderer parses whatever shows up.- Only
followups-tool+flashcards-toolare live-captured — the other seven runtimes and the multi-fragmentSegmentAssemblerboundary rules are INFERRED (unit-tested red/green, not live-verified). Source:genui/segments.jsheader. - RAG-driven vs. config-driven emission is unverified — whether
video_gallery/external_video/show_linkfire from RAG hits or pure prompt tuning is not documented; the author-time lever is the capability + prompt, but the trigger is the brain's discretion. - Backend may add runtimes outside this set — e.g.
gen-ui-composer-tool,gen-ui-components-tool,kaltura-video-player-tool. They are NOT inRUNTIMES; the renderer routes them to{kind:'unknown'}+onUnhandledrather than faking a known kind. sourcesneeds a knowledge base to cite from — ground a new agent viaknowledge.addRecordknowledge_ids(ungated; see API-REFERENCE.md § Ground the Agent).sourcesthen renders the brain's real retrieved citations.
entryIdplayback needs the Kaltura player —video-gallerypreservesentryId; whenembedUrlis present (requirespartnerId+uiConfId), the SDK renders an inline player; without those the host renders byentryId.
Pointers (source of truth)
| Topic | File |
|---|---|
| Runtime catalog + normalize + parse | src/experience/genui/parse.js |
| The 9 default renderers | src/experience/genui/renderers/*.js (+ index.js map + WIDGET_KINDS) |
DOM mount helper (mountWidget + kgenui classes) |
src/experience/genui/renderers/mount.js |
| Multi-fragment assembly | src/experience/genui/segments.js |
| Dispatch + dual-mode + fallback | src/experience/genui/renderer.js |
Wire enums (GENUI_RUNTIMES, segmentKind, collectConverse) |
src/core/stream.js |
force_experience (EXPERIENCES) + join hardcode |
src/experience/wire.js |
| HTTP converse + validation | src/management/conversations.js |
Capability gating (CAPABILITY_INFO) |
src/management/capabilities.js |
| Safety primitives | src/core/safety.js |
submitStructuredDataForm / sendScreenShot |
src/experience/session.js |
Wire segment shape + force_experience |
WIRE-PROTOCOL.md §4e, §7 |