Reference

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-tool and unisphere.widget.genie below 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)

  1. 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.
  2. Emit — the brain writes a fenced block with widgetName:"unisphere.widget.genie" + runtimeName. message_service turns it into a unisphere-tool segment.
  3. Stream — segments arrive as agent_raw_text deltas (live socket) or SSE/NDJSON lines (HTTP converse). A single widget can span multiple fragments.
  4. AssembleSegmentAssembler (genui/segments.js) buffers fragments and flushes a complete widget on a boundary change (different runtime or speechId, 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 — ExperienceRenderer mounts the same typed {kind:'error', data:{runtime, message}} fallback it uses for a throwing custom renderer.
  5. ParseparseWidget(segment) (parse.js) → {widgetName, runtimeName, runtime, model}. parseContent is forgiving: object content is used as-is; a string is JSON-parsed, else parsed as a loose key: value block, else preserved under .raw. Never throws.
  6. RenderExperienceRenderer._renderWidget dispatches model to the runtime's renderer → {kind, data, runtime, runtimeName, _meta}. Your mount(descriptor) turns it into DOM.

Two delivery paths (this is the #1 gotcha)

force_experience — a hint, not a contract

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.

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.

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}]}}.

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.

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

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:

Restrictions & gotchas (read before you build)

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
Click to talk with Nova — she knows this whole SDK.