UI plugins, UI renderers and the catalog of visual components
The interface of an application is made of UI plugins: the Reactor plugins of
src/apps/plugins/ the Loop editor assembles — the chat and its composer and title
bar, the notebook and its toolbar, the document and its toolbar, the surfaces drawn in
the conversation, an application's page, its rules and approvals, its activity, the
Sign in at a company card, the page layout… A UI spec is one of them
(agentspecs/ui/*.yaml): the name the plugin registers under, its Contributions — the
Slots it fills, the Contribution Points it contributes to, the Commands it adds —, the
Contribution Points it offers, its settings, the components it renders and the
renderers it may be exposed through.
A UI renderer is how a plugin's interface reaches a host that is not Datalayer's
own page — A2UI today; MCP Apps and MCP UI are listed and not enabled. Renderers are
specs of their own (agentspecs/ui-renderers/*.yaml). An agent or an application
names its UI plugins and, per plugin, the renderer:
ui:
- plugin: a2ui-surface
renderer: a2ui
- plugin: chat
A renderer named as a UI — the old shape, ui: a2ui — is refused by agentspecs, by
checkAppspec (uiProblems) and by make specs, with no compatibility reading.
An application lists what it is drawn with. Every application of the
catalogue lists the plugins appPreset (src/apps/apps/AppRenderer.tsx) mounts for
its kind and layout, beyond those every conversation has: chat for a conversation —
it stands for the conversation base the runtime mounts, its header, composer and the
plugins every conversation comes with, not only the message list —
a2ui-surface through a2ui when its interface.outputs has
application/json+a2ui, pap-sign-in when it signs people in at companies
(pap.connections), page-layout, app-page and canvas-blocks when its layout is
page or split, app-page alone for a decision, app-components for components of
its own. What a host adds — the Preview's rules, activity and computer, a window — is
not the application's. The mapping is one function, uiFor (uiOf reads it off a
spec) in src/apps/apps/appspec.ts, and agentspecs' ui_for is the same: both are held
to agent_runtimes/tests/fixtures/app_ui.json (src/apps/__tests__/app-ui.test.ts,
agent_runtimes/tests/test_app_ui.py). emptyAppspec(kind) — the Studio's Start
blank — and loop apps init seed a new application's ui with it.
src/apps/__tests__/app-preset.test.tsx holds each catalogue application's ui to
what its preset mounts, and agentspecs' test_ui.py to what its spec says.
The specs are the plugins' registrations. src/apps/__tests__/ui-specs.test.ts
builds each plugin on a reactor, starts it, and holds what it registers — its name,
Slots, Contribution Points, Commands, offered Contribution Points and settings — to its
spec, both ways; and every folder of src/apps/plugins/ has a spec, or a reason it has
none (NOT_UI in src/apps/__tests__/fixtures/uiPluginRegistrations.ts). A plugin
added without its spec, a spec whose plugin went, or a Slot added to a plugin and not
to its spec fails there.
The plugin that draws A2UI surfaces (a2ui-surface) hosts the catalog of visual
components (C-13): A2UI's 18 standard v0.9 components (Text, Row, Card,
Button, ChoicePicker…) and Datalayer's own — Table, Chart, FileUpload, Chat,
Evidence, Form and Download. Every one has a version and carries its properties
as a JSON Schema of the catalog's own — a standard one's named, required and chosen as
A2UI's are; Datalayer's own with what it can be bound to, the events it reports and a
valid example.
Their fields, every component and how an Appspec's surface names one are documented in Agentspecs: UI, Renderers and the catalog of visual components. This page is about the lookups Agent Runtimes generates from them.
The generated lookups
make specs generates both kinds into one catalogue per language and kind — do not
edit them by hand:
| Python | TypeScript | |
|---|---|---|
| Modules | agent_runtimes.specs.ui, agent_runtimes.specs.ui_renderers | src/specs/ui.ts, src/specs/uiRenderers.ts |
| The UI plugins, by id | UI_CATALOGUE | UI_CATALOGUE |
| One UI plugin | get_ui(ui_id) | getUI(uiId) |
| Every UI plugin | list_ui() | listUI() |
| The renderers, by id | UI_RENDERER_CATALOGUE | UI_RENDERER_CATALOGUE |
| One renderer | get_ui_renderer(renderer_id) | getUiRenderer(rendererId) |
| The components, by the name a surface gives them | COMPONENT_CATALOGUE | COMPONENT_CATALOGUE |
| One component, or none | get_component(name) → ComponentSpec | None | getComponent(name) → ComponentSpec | undefined |
| Every component | list_components() | listComponents() |
| Types | UISpec, UiContributionsSpec, UiRendererSpec, UiChoiceSpec, ComponentSpec (agent_runtimes.types) | UISpec, UiRendererSpec, UiChoice, ComponentSpec (src/types/agentspecs.ts) |
COMPONENT_CATALOGUE holds the components the UI plugins render; a component none
renders is None / undefined.
The Canvas's palette is a plugin per UI plugin that renders components
(canvas-blocks, @datalayer/loop-plugin-ui-<id>), and an organization turns one off
by the UI plugin's id (plugins_off in IAM: a2ui-surface).
A ComponentSpec says:
| Field | Meaning |
|---|---|
id | The name a surface gives it (Table). |
name | What a builder reads on the palette. |
description | What it is for, in a sentence. |
category | text, input, action, data, conversation, media or layout. |
emoji | Its face on the palette. |
version | Its version in the catalog: A2UI's standard ones 0.9.0, Datalayer's own 1.0.0. |
standard | Named and drawn as its protocol says (A2UI's basic catalog). |
properties | Its properties as a JSON Schema: what a builder sets — what the Canvas draws its properties form from, and what types its Python call. A value A2UI lets be bound ({"path": …}) is written here as the value itself. |
bindings | For Datalayer's own: what it shows and what it sends. |
events | What it reports. |
example | A configuration of it, valid against properties. |
from agent_runtimes.specs.ui import COMPONENT_CATALOGUE, get_component
table = get_component("Table")
table.properties["required"] # ['columns']
table.bindings.shows, table.events # (['rows'], ['select'])
get_component("Marquee") # None: no UI plugin renders it
len(COMPONENT_CATALOGUE) # 25
import { getComponent, listComponents } from '@datalayer/agent-runtimes/lib/specs/ui';
const chart = getComponent('Chart');
chart?.properties; // its JSON Schema: kind, x, y, title, series
listComponents().filter(c => !c.standard).map(c => c.id);
// ['Table', 'Chart', 'FileUpload', 'Chat', 'Evidence', 'Form', 'Download']
Where they are read
- The application checks (
src/apps/apps/checks.ts):componentNamedisgetComponent, andcheckAppsays an application is not ready when itsinterface.components, or a node of itssurface, names a component the catalog does not have — the same sentences asapp_problemsin Agentspecs. - Python (
Application.component, andapp.ui, below): every component placed from Python is checked against its schema, a standard one too. - Forms (C-16):
checkApprefuses aFormwhoseschemais not an object of named fields, or that requires a field it does not ask (formProblems, agentspecs'form_problems); and when an action arrives, the runtime checks the values aFormsends with it against the same schema (agent_runtimes.apps.forms.form_values_refused): refused, the session API answers 422 with the sentence — “The quote” was sent what its fields refuse: seats: 0 is less than the minimum of 1. — and the application's handler is not called. An application's settings are a form of the same kind (interface.settings), checked bycheckAppspecas the form “settings” (a list of settings, their old shape, is refused) and by the runtime when a run or a settings change carries them (refused_by, the same sentence). - Tests hold the catalogue to the plugins (
agent_runtimes/tests/test_ui_components.py): the component catalog is A2UI's own, every component has a version and a schema, Datalayer's carry an example their schema meets, and an unknown name isNone.
Typed calls from Python
The same generation writes every component as a typed call (C-15):
SurfaceComponents in agent_runtimes.specs.ui, which an application reaches
as app.ui. One method per component, named for it in snake case (Table → table,
TextField → text_field), whose keyword arguments are its properties, typed from its
JSON Schema — an enumeration a Literal, a required property without a default — and,
for Datalayer's own, its bindings; each may be bound instead ({"path": …}). An IDE
completes them, a type checker refuses a wrong value before the application runs, and
the call writes the node app.component(id, component, **properties) writes, checked
against the same schema:
app.ui.column("root", children=["runs"])
app.ui.table("runs", columns=["model", "cost"], page_size=10, rows={"path": "/runs"})
app.ui.text("hello", text="Hello", variant="huge") # refused: 'huge' is not one of h1…body
How they are drawn
datalayerCatalog (src/components/a2ui, @datalayer/agent-runtimes/lib/components/a2ui)
is the A2UI catalog every Datalayer surface draws from — the decision surface,
InlineSurface, A2uiView, an application's page and the Studio's components page.
It holds A2UI's basic components and a renderer for each of Datalayer's own six
(C-18), under the basic catalog's id, with visible_when on every one.
Each renderer is made by @a2ui/react's own createComponentImplementation, so it
binds and acts exactly as a basic component does. Its A2UI schema is built from the
catalog (ownComponentSchema(id)): every property its JSON Schema declares, typed as
it says; each name in bindings.shows and bindings.sends a binding — a
{"path": …} into the data model, or a value given in place; an action, written as a
Button's; and weight. The schema is strict: a key the catalog does not declare
refuses the tree. Sending is A2UI's path: the renderer writes what a person did where
its sends binding points, then dispatches its action, whose context reads that path.
| Component | Shows | Sends, then dispatches its action |
|---|---|---|
Table | rows: a list of records, under columns, page_size at a time | selected: the row chosen by pointer or by Enter/Space, when selectable |
Chart | points: a list of records, drawn with ECharts as a bar, line, area or scatter of y by x, a series per value of series; its numbers also as a table under The numbers | — |
FileUpload | — | files: [{name, type, size, data_url}], once each file is of a kind in accept and within max_mb |
Chat | messages: {role: 'user' | 'assistant', text} or {role: 'tool', name, args, result} (shown while show_tools), drawn by the chat's ChatMessageList; welcome and starters before the first | message: what is written in the composer, or a starter chosen |
Evidence | sources: {title, url, passage}, the first max_items and the rest behind more | — (opening a source dispatches its action) |
Form | values: the record the form starts from, its fields generated from schema with @datalayer/primer-rjsf | values: the form's values when it is valid and sent; without an action, a set of settings, written as they are filled, no button |
{"id": "runs", "component": "Table", "columns": ["model", "cost"], "selectable": true,
"rows": {"path": "/runs"}, "selected": {"path": "/chosen"},
"action": {"event": {"name": "choose", "context": {"run": {"path": "/chosen"}}}}}
DRAWN_OWN_COMPONENTS (lib/components/a2ui/datalayer/drawn) lists them without
loading the renderers; the renderers' module refuses to load when it and the catalog
disagree, so a component added to the catalog without a renderer fails at once.
On an application's page, a chat publishes its conversation at /messages for a
Chat block, and a widget takes a FileUpload's files at /files and hands a text
file's contents to its agent when it is run (upload runs it at once): see An
application's page on the Loop page.