| Title: | Generative UI for 'shiny' |
|---|---|
| Description: | Build interactive user interfaces for 'shiny' applications through a conversation with a large language model (LLM). Developers choose a set of reusable components, and the model arranges and updates those components as the user describes what they need. Each component's inputs are checked before it is shown, and the model supplies data rather than executable code. Applications can also save and replay the sequence of interface changes without contacting a model. For background on generative user interfaces, see Leviathan et al. (2026) <doi:10.48550/arXiv.2604.09577>. |
| Authors: | Nan Xiao [aut, cre, cph] (ORCID: <https://orcid.org/0000-0002-0250-5673>) |
| Maintainer: | Nan Xiao <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 0.2.0 |
| Built: | 2026-09-22 00:27:04 UTC |
| Source: | https://github.com/nanxstats/shinygenui |
A genui_call is the normalized representation of one model-issued tool
call: the tool name plus its arguments as plain data. genui_dispatch()
consumes these. In production they are produced by the compiled ellmer
tools; in tests and replays you can hand-build them.
genui_call(tool, args = list())genui_call(tool, args = list())
tool |
Tool name: a component name from the catalog, or one of the
built-ins |
args |
Named list of arguments as supplied by the model. For
|
A genui_call object.
genui_call("scatter_plot", list(x = "mpg", y = "hp")) genui_call("update_component", list(id = "c1", args = list(x = "wt"))) genui_call("remove_component", list(id = "c1"))genui_call("scatter_plot", list(x = "mpg", y = "hp")) genui_call("update_component", list(id = "c1", args = list(x = "wt"))) genui_call("remove_component", list(id = "c1"))
Place this anywhere in your UI, typically as the main content next to a
shinychat::chat_ui() sidebar. Components emitted by the model are
inserted into it progressively as tool calls complete. Pair it with a
genui_server() call using the same id.
genui_canvas(id, ..., placeholder = "Components will appear here.")genui_canvas(id, ..., placeholder = "Components will appear here.")
id |
Module id, matching the |
... |
Attributes and initial children added to the canvas |
placeholder |
Text shown while the canvas is empty. |
An htmltools::tag() object.
genui_canvas("canvas")genui_canvas("canvas")
A container the model can place other components into: create the row,
then create children with parent_id set to the row's instance id.
Children lay out in a responsive grid inside the row's card. Removing
the row removes its children. Add it to your genui_catalog() alongside
genui_components_bslib() (or your own components) to let the model
group related views.
genui_card_row()genui_card_row()
A genui_component() object with container = TRUE.
catalog <- genui_catalog( genui_card_row(), genui_component( name = "note_card", description = "A card showing a short note.", args = list(text = "The note text."), ui = function(id, args) htmltools::p(args$text) ) )catalog <- genui_catalog( genui_card_row(), genui_component( name = "note_card", description = "A card showing a short note.", args = list(text = "The note text."), ui = function(id, args) htmltools::p(args$text) ) )
A catalog is the finite set of components the model is allowed to render.
Each component compiles to one ellmer::tool(); the built-in lifecycle
tools (update_component, remove_component, clear_canvas) are
registered alongside it by genui_server().
genui_catalog(...)genui_catalog(...)
... |
|
A genui_catalog object: a named list of components, keyed by
component name.
note <- genui_component( name = "note_card", description = "A card showing a short note.", args = list(text = "The note text."), ui = function(id, args) htmltools::p(args$text) ) catalog <- genui_catalog(note) names(catalog)note <- genui_component( name = "note_card", description = "A card showing a short note.", args = list(text = "The note text."), ui = function(id, args) htmltools::p(args$text) ) catalog <- genui_catalog(note) names(catalog)
A component is one entry in the finite catalog that constrains what the
model can render. It couples a model-facing tool schema (name,
description, typed args) with developer-written rendering code: a
ui function plus an optional server function, instantiated together
as a dynamic Shiny module every time the model creates or updates an
instance. The model only ever supplies data arguments validated against
the declared types; it never emits code.
genui_component( name, description, args = list(), ui, server = NULL, check = NULL, container = FALSE, width = c("auto", "wide", "full") )genui_component( name, description, args = list(), ui, server = NULL, check = NULL, container = FALSE, width = c("auto", "wide", "full") )
name |
Tool name the model sees. A snake_case string: lowercase
letters, digits, and underscores, starting with a letter. Must be unique
within a catalog and must not collide with the built-in lifecycle tools
( |
description |
One to three sentences telling the model what the component shows and when to use it. This is the model-facing documentation for the component, so write it well. |
args |
Named list of ellmer type specifications
(for example |
ui |
|
server |
Optional |
check |
Optional |
container |
If |
width |
Layout hint for the canvas grid: |
A genui_component object.
genui_component( name = "note_card", description = "A card showing a short markdown note. Use for narrative text that should live on the canvas rather than in the chat.", args = list( title = "A short title for the card.", text = ellmer::type_string("The markdown body text.") ), ui = function(id, args) { htmltools::div( htmltools::h5(args$title), htmltools::p(args$text) ) } )genui_component( name = "note_card", description = "A card showing a short markdown note. Use for narrative text that should live on the canvas rather than in the chat.", args = list( title = "A short title for the card.", text = ellmer::type_string("The markdown body text.") ), ui = function(id, args) { htmltools::div( htmltools::h5(args$title), htmltools::p(args$text) ) } )
A small, general-purpose catalog: a value box, a Markdown card, a data
table, a scatter plot, and a histogram with an embedded bin-count slider
(the reference interactive component: dragging the slider re-renders at
Shiny speed with no LLM round trip). Pass the result to
genui_catalog(), optionally alongside your own components.
genui_components_bslib(data = NULL)genui_components_bslib(data = NULL)
data |
Optional data frame used to ground column arguments as enums
at catalog build time. Typically the same data your |
When data is supplied, column arguments become enums of the actual
column names, so a hallucinated column is a schema violation the model
must correct. Each component also validates columns against the live
data through its check() hook at render time.
A list of genui_component() objects.
catalog <- genui_catalog(genui_components_bslib(data = mtcars)) names(catalog)catalog <- genui_catalog(genui_components_bslib(data = mtcars)) names(catalog)
The pure core of shinygenui: given the catalog, one normalized call, and the current instance state, either return a plan describing what should happen (create, update, remove, or clear) or signal a classed error whose message is written for the model to read and correct. No Shiny session, registry mutation, or LLM is involved; the Shiny executor applies the returned plan.
genui_dispatch(catalog, call, state, data = NULL)genui_dispatch(catalog, call, state, data = NULL)
catalog |
|
call |
A |
state |
Current instance state: a |
data |
Current value of the app's data object, passed to component
|
A genui_plan object, a list with at least action (one of
"create", "update", "remove", "clear") plus the fields the
executor needs: id, component, args (full args to render),
delta (validated partial args, updates only), parent_id, and ids
(teardown order, removes and clears only).
catalog <- genui_catalog( genui_component( name = "note_card", description = "A card showing a short note.", args = list(text = "The note text."), ui = function(id, args) htmltools::p(args$text) ) ) state <- list(instances = list(), next_id = 1L) genui_dispatch(catalog, genui_call("note_card", list(text = "hi")), state)catalog <- genui_catalog( genui_component( name = "note_card", description = "A card showing a short note.", args = list(text = "The note text."), ui = function(id, args) htmltools::p(args$text) ) ) state <- list(instances = list(), next_id = 1L) genui_dispatch(catalog, genui_call("note_card", list(text = "hi")), state)
Renders the packaged whisker template (inst/prompts/system.md) with the
catalog's components and an optional developer-supplied context string.
The result instructs the model to narrate briefly in chat while placing
visuals through tools, to reuse update_component when the user refines
an existing view, and to prefer few, dense components.
genui_prompt(catalog, context = NULL, template = NULL)genui_prompt(catalog, context = NULL, template = NULL)
catalog |
|
context |
Optional string appended as an "App context" section: the data schema, a few sample rows, a business glossary; whatever the model needs to ground its answers. Raw data is never included unless you put it here yourself. |
template |
Optional path to an alternative whisker template, or a
template string. It receives |
A string: the assembled system prompt.
catalog <- genui_catalog( genui_component( name = "note_card", description = "A card showing a short note.", args = list(text = "The note text."), ui = function(id, args) htmltools::p(args$text) ) ) cat(genui_prompt(catalog, context = "The data is mtcars."))catalog <- genui_catalog( genui_component( name = "note_card", description = "A card showing a short note.", args = list(text = "The note text."), ui = function(id, args) htmltools::p(args$text) ) ) cat(genui_prompt(catalog, context = "The data is mtcars."))
Folds a trace (from genui_trace(), or the trace reactive returned by
genui_server()) through the same validate-and-execute pipeline the
model's tool calls use, recreating every instance in a genui_canvas()
with id target. Instance ids come back identical because ids are
assigned deterministically and never reused. Embedded inputs return at
their default values: input state is ephemeral by design and not part of
the trace.
genui_replay( trace, catalog, target, data = NULL, session = shiny::getDefaultReactiveDomain() )genui_replay( trace, catalog, target, data = NULL, session = shiny::getDefaultReactiveDomain() )
trace |
A trace list, as returned by |
catalog |
The |
target |
Module id of the |
data |
Optional reactive (or function) returning the data object,
passed to component servers and |
session |
The Shiny session (defaults to the current reactive domain). |
Entries that no longer validate (for example after the catalog changed) are skipped with a warning; the rest of the trace still replays. The target canvas is emptied first, so replaying twice is idempotent.
(Invisibly) a list with reactives trace and instances
describing the rebuilt canvas, as in genui_server().
note <- genui_component( name = "note_card", description = "A card showing a short note.", args = list(text = "The note text."), ui = function(id, args) htmltools::p(args$text) ) catalog <- genui_catalog(note) saved_trace <- list(list( op = "create", id = "c1", component = "note_card", args = list(text = "Hello") )) shiny::testServer( function(input, output, session) { suppressWarnings(genui_replay(saved_trace, catalog, target = "canvas")) }, { stopifnot(identical(names(session$returned$instances()), "c1")) } )note <- genui_component( name = "note_card", description = "A card showing a short note.", args = list(text = "The note text."), ui = function(id, args) htmltools::p(args$text) ) catalog <- genui_catalog(note) saved_trace <- list(list( op = "create", id = "c1", component = "note_card", args = list(text = "Hello") )) shiny::testServer( function(input, output, session) { suppressWarnings(genui_replay(saved_trace, catalog, target = "canvas")) }, { stopifnot(identical(names(session$returned$instances()), "c1")) } )
Wires a genui_catalog() to an ellmer Chat: compiles every component
into a schema-validated tool, registers the built-in lifecycle tools
(update_component, remove_component, clear_canvas), installs the
assembled system prompt, and executes validated tool calls against the
genui_canvas() with the matching id. Components stream onto the
canvas progressively as the model emits tool calls; validation and
rendering failures are returned to the model as tool errors and never
crash the session.
genui_server( id, catalog, chat, data = NULL, chat_id = NULL, greeting = NULL, system_prompt = NULL )genui_server( id, catalog, chat, data = NULL, chat_id = NULL, greeting = NULL, system_prompt = NULL )
id |
Module id, matching the |
catalog |
|
chat |
An ellmer |
data |
A reactive (or function) returning the app's current data
object. It is passed to component |
chat_id |
Id of a |
greeting |
Optional markdown welcome message, shown while the chat
is empty and dismissed after the first user message (only used when
|
system_prompt |
System prompt to install on |
With chat_id, the package connects a shinychat::chat_ui() you placed
in the UI using shinychat::chat_server(). It handles asynchronous
streaming, cancellation, and attachments. Set allow_attachments = FALSE
in chat_ui() to disable uploads; attachments provide context to the
model and do not replace the component data. Conversation history is
disabled because restoring a conversation also requires restoring its
canvas and registry. With chat_id = NULL you run your own loop on
chat; the registered tools work all the same.
Create the Chat object inside your server function, one per session.
Sharing a single Chat across sessions would cross-wire the tool
closures and leak conversation history between users.
(Invisibly) a list with chat (the wired Chat), and the
reactives trace (the ordered call trace, see genui_trace()) and
instances (the live instance state, a named list keyed by id).
update_component re-instantiates the component's module with the merged
arguments inside the instance's stable shell: same canvas position, same
ids, no page flicker. Because the module restarts, embedded input state
(like the starter histogram's bin slider) resets to its defaults on
update; snapshotting and restoring embedded input values across updates
is explicitly future work.
Any failure while handling a tool call — schema validation, a
component's check() hook, or a rendering error — is signaled as a
regular R condition. ellmer catches it and returns
conditionMessage() to the model as the tool error, so the model can
correct its arguments and retry; the Shiny session itself never crashes,
and a failed call never leaves a half-rendered component behind.
Failures are always logged to the app's server log; set
options(shinygenui.verbose = TRUE) to also log successful canvas
operations.
note <- genui_component( name = "note_card", description = "A card showing a short note.", args = list(text = "The note text."), ui = function(id, args) htmltools::p(args$text) ) catalog <- genui_catalog(note) chat <- ellmer::chat_openai( model = "gpt-5.6-sol", credentials = function() list(api_key = "not-used") ) shiny::testServer( genui_server, args = list(id = "canvas", catalog = catalog, chat = chat), { stopifnot("note_card" %in% names(chat$get_tools())) } )note <- genui_component( name = "note_card", description = "A card showing a short note.", args = list(text = "The note text."), ui = function(id, args) htmltools::p(args$text) ) catalog <- genui_catalog(note) chat <- ellmer::chat_openai( model = "gpt-5.6-sol", credentials = function() list(api_key = "not-used") ) shiny::testServer( genui_server, args = list(id = "canvas", catalog = catalog, chat = chat), { stopifnot("note_card" %in% names(chat$get_tools())) } )
The trace is the spec of record for a canvas: every validated call the
model made (create, update, remove, clear), in order, as plain
JSON-friendly lists. genui_replay() can rebuild the canvas from it with
no LLM configured. Embedded input state is intentionally ephemeral and
never recorded.
genui_trace(session = shiny::getDefaultReactiveDomain(), id = NULL)genui_trace(session = shiny::getDefaultReactiveDomain(), id = NULL)
session |
The Shiny session (defaults to the current reactive domain). |
id |
The |
A reactive expression returning the trace: a list of entries of
the form list(op = "create", id = "c1", component = "...", args = list(...)) (plus parent_id for children; update entries carry the
validated argument delta). Persist it with saveRDS() to replay in a
later session.
note <- genui_component( name = "note_card", description = "A card showing a short note.", args = list(text = "The note text."), ui = function(id, args) htmltools::p(args$text) ) catalog <- genui_catalog(note) chat <- ellmer::chat_openai( model = "gpt-5.6-sol", credentials = function() list(api_key = "not-used") ) shiny::testServer( genui_server, args = list(id = "canvas", catalog = catalog, chat = chat), { trace <- genui_trace(session) stopifnot(identical(trace(), list())) } )note <- genui_component( name = "note_card", description = "A card showing a short note.", args = list(text = "The note text."), ui = function(id, args) htmltools::p(args$text) ) catalog <- genui_catalog(note) chat <- ellmer::chat_openai( model = "gpt-5.6-sol", credentials = function() list(api_key = "not-used") ) shiny::testServer( genui_server, args = list(id = "canvas", catalog = catalog, chat = chat), { trace <- genui_trace(session) stopifnot(identical(trace(), list())) } )