Skip to main content

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:

PythonTypeScript
Modulesagent_runtimes.specs.ui, agent_runtimes.specs.ui_rendererssrc/specs/ui.ts, src/specs/uiRenderers.ts
The UI plugins, by idUI_CATALOGUEUI_CATALOGUE
One UI pluginget_ui(ui_id)getUI(uiId)
Every UI pluginlist_ui()listUI()
The renderers, by idUI_RENDERER_CATALOGUEUI_RENDERER_CATALOGUE
One rendererget_ui_renderer(renderer_id)getUiRenderer(rendererId)
The components, by the name a surface gives themCOMPONENT_CATALOGUECOMPONENT_CATALOGUE
One component, or noneget_component(name) → ComponentSpec | NonegetComponent(name) → ComponentSpec | undefined
Every componentlist_components()listComponents()
TypesUISpec, 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:

FieldMeaning
idThe name a surface gives it (Table).
nameWhat a builder reads on the palette.
descriptionWhat it is for, in a sentence.
categorytext, input, action, data, conversation, media or layout.
emojiIts face on the palette.
versionIts version in the catalog: A2UI's standard ones 0.9.0, Datalayer's own 1.0.0.
standardNamed and drawn as its protocol says (A2UI's basic catalog).
propertiesIts 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.
bindingsFor Datalayer's own: what it shows and what it sends.
eventsWhat it reports.
exampleA 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): componentNamed is getComponent, and checkApp says an application is not ready when its interface.components, or a node of its surface, names a component the catalog does not have — the same sentences as app_problems in Agentspecs.
  • Python (Application.component, and app.ui, below): every component placed from Python is checked against its schema, a standard one too.
  • Forms (C-16): checkApp refuses a Form whose schema is 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 a Form sends 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 by checkAppspec as 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 is None.

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.

ComponentShowsSends, then dispatches its action
Tablerows: a list of records, under columns, page_size at a timeselected: the row chosen by pointer or by Enter/Space, when selectable
Chartpoints: 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
Chatmessages: {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 firstmessage: what is written in the composer, or a starter chosen
Evidencesources: {title, url, passage}, the first max_items and the rest behind more— (opening a source dispatches its action)
Formvalues: the record the form starts from, its fields generated from schema with @datalayer/primer-rjsfvalues: 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.