@setsquare/ui-agent
v0.1.0
Published
UI an agent puts together and takes away: a Setsquare renderer for A2UI, which refuses a description that fails a check and tells the agent why.
Maintainers
Readme
@setsquare/ui-agent
UI an agent puts together and takes away, for @setsquare/ui. An agent describes a surface (a form, a table, an approval) in A2UI v0.9.1, the open protocol, using a catalogue of Setsquare components. The renderer checks the description before anything is shown, refuses one that fails a check and tells the agent why, and when the task is done shrinks the surface to a one-line receipt.
Not published yet. The renderer (
.), the catalogue and validator (./catalog) and the MCP App (./mcp-app) are here. The design, and the WCAG reading behind it, is in docs/proposals/ui-agent.md.
The agent chooses what is shown; Setsquare chooses how it looks. The catalogue has no prop for a class, style, colour, size, icon or heading level, so contrast, target size and the AA/AAA switch hold whatever the agent sends.
Entries
@setsquare/ui-agent: the renderer, for the browser:useSurfaces,AgentSurfaces,SurfaceStore,agentLabels.@setsquare/ui-agent/catalog: the catalogue, its rules text and the validator. No React and nothing from the DOM, so an agent's server, or an MCP server, checks a description with it before anything goes to the browser.@setsquare/ui-agent/mcp-app: what an MCP server needs to show surfaces in a chat host as an MCP App: the view, one HTML document, and the tool and resource declarations. No React and nothing from the DOM either.
The renderer
'use client';
import { AgentSurfaces, useSurfaces } from '@setsquare/ui-agent';
const surfaces = useSurfaces({
send: (message, metadata) => agent.post(message, metadata), // actions and errors, back to the agent
confirmActions: ['approve-invoices'],
headingLevel: 3, // in a RightPanel, whose title is an h2
});
useEffect(() => agent.on('a2ui', (messages) => surfaces.receive(messages)), [surfaces]);
// From a stream, one message at a time: surfaces.push(message), checked in batches 50 ms apart.
<RightPanel title="Assistant">
<AgentSurfaces surfaces={surfaces} labels={{ assistant: 'Ledger assistant' }} closable />
</RightPanel>- Every entry draws a core component, at the page's conformance level, theme and brand: its targets, focus, contrast and words are core's. Heading levels come from the nesting.
- A surface in another language (
langon the Surface) runs the way that language does:dirfrom React Aria's reading of the language, so Arabic or Hebrew is right to left, and React Aria's locale, so its components mirror. A Text with its ownlanggets its owndir, so an English line inside an Arabic surface runs left to right. The product's words stay the product's. - A surface is a region named for the assistant and its title, with the assistant's name above the title.
role="group"for a feed of many. - Appearing: announced politely, without taking focus, unless
surfaces.requested(id)said the person's own press asked for it. - Until it's whole: a loading state; after
completeWithin(5 s) with parts missing, one line saying it couldn't be shown, and the agent is told what never came. - Acting: a form's checks show in core's error summary and beside each field, and block the send. A check is A2UI's
{condition, message}, or flat,{call, args, message}, as A2UI's own protocol examples write it. A destructive button, or one for an action inconfirmActions, confirms first. Then the action goes with its context read from the data model (and the data model itself, as A2UI'sa2uiClientDataModelmetadata, when the surface asked for it), and the button waits; afteractionTimeout(30 s) the surface says the assistant didn't reply. - What the person typed is theirs until they act: an agent update to it is refused, and the agent is told why.
- Going: after the person's action, a one-line receipt that takes focus if focus was inside; one the person was typing into stays under a notice until they close it; one nobody touched is a status message.
closableadds a close button, which sends the surface'sdismissaction or callsonDismiss. - The product's logo (
logo): any element, an<img alt="">or an SVG, drawn in a 20px square beside the assistant's name on every surface and hidden from assistive technology, since the name is what's read. It's the product's, never the agent's. labelsreplace every word the renderer brings; the components' own words are core's, throughSetsquareLocale.- Written with no React 19-only API, since the design-system artifact draws it on React 18. The tests run it on 19, so 18 isn't tested.
Tested: the store's lifecycle in Node with its timers faked; pnpm package:verify, which installs the tarball in a Next.js app, draws a surface a server component checked, and saves through it to a focused receipt; the docs site's scripted agent, run from its first, refused try to the receipt; and in Chromium (test/browser/agent-surfaces.mjs, at AA and AAA, light and dark, two brands) targets, overlaps, positioning, underlined links and cursors on its three cards, and the lifecycle on surfaces the suite drives. Not tested yet: a RightPanel shown as a Sheet below 1024px. Nothing here has been tried with a screen reader yet.
The catalogue and the validator
import { CATALOG_ID, catalogText, hostText, rules, validate, toErrorMessage } from '@setsquare/ui-agent/catalog';
// What this product allows: the validator holds the model to it, and the prompt tells the model.
const options = {
links: { origins: ['https://help.example.com'] }, // besides paths on this site
images: { origins: ['https://images.example.com'] }, // none: images are refused
confirmActions: ['approve-invoices', 'delete-records'], // their buttons need confirm
actions: ['approve-invoices', 'delete-records', 'save-details'], // any other event name is refused
headingLevel: 3, // where the surface's title sits: 3 in a RightPanel
};
// In the prompt: the rules text, the product's options in words, and the catalogue in its short form, every entry
// and prop in about 9,700 characters. `catalog` is the full JSON Schema, served at CATALOG_ID, for tools that read
// schemas.
const prompt = `${rules}\n${hostText(options)}\n${catalogText()}`;
// On the server, before anything goes to the browser:
const { ok, issues } = validate(messages, options);
if (!ok) return issues.map(toErrorMessage); // A2UI's VALIDATION_FAILED, back to the model- 28 entries:
Surface(the root),Column,Row,Card,Disclosure,Text,List,Link,Image,StatusBadge,Alert,StatList,Stat,Table,Steps,ProgressBar,CodeBlock,Form,TextField,TextArea,NumberField,Select,ComboBox,RadioGroup,CheckboxGroup,Checkbox,DatePickerandButton. Four check functions:required,length,numericandemail. - The rules (
RULES): the protocol's (envelope, catalogue, surfaces, paths, the tree), and the catalogue's own: a visible name on everything, noaccessibility.label, text alternatives, WCAG's input purposes, no placeholder words or vague link text, one primary button, a confirmation for what commits or deletes, only the actions the product lists, headings that core can draw, a source for every figure, language tags, https or a path for links, images only from listed origins, no password, code or payment-card fields, the person's edits winning, bound values of the right type, and the host's limits. The proposal's section 5.2 gives the basis of each. - Each issue is A2UI v0.9.1's
VALIDATION_FAILED: a JSON Pointer into the payload of the message that failed, and a message that starts with the rule and ends with the fix:[accessible-name] Button 'approve': its label is empty. Write the words it needs: what it is, or what it does.A prop the entry doesn't have is answered with the one the model most likely meant and the entry's props, so a model whose catalogue was cut short can still repair:[invalid-prop] TextField 'email': 'isRequired' isn't a prop here. Use 'required'. Its props: id, component, accessibility, label, description, value, required, inputType, autoComplete, checks. - Two forms of the catalogue.
catalogis the JSON Schema, about 47,000 characters, which a schema tool reads.catalogText()is the same entries and props in words, about 9,700 characters, which a model reads: a host may cut a long tool description, and the Claude app cut the JSON Schema form at 16,384 characters (2026-10-21). - A batch with any issue is refused whole, and what was there stays as it was.
createValidator(options)holds surfaces across batches, as a renderer or a long-running server does:receive(messages, { complete }),surface(id),missing(id), andwrite/actedfor the person's edits.
Tested in test/: every rule made to fire, each with its path; every message of a surface using all 28 entries valid under A2UI's own schemas (vendored, at a named commit) with this catalogue mapped in; ajv and the validator's shape checks agreeing on 10,242 messages; the enums equal to core's; and no React or DOM in ./catalog's module graph.
It agrees with @setsquare/ui-blocks' runChecks, which reads HTML (test/agreement.test.ts): every surface the validator accepts, drawn on the server, has nothing for runChecks to find at AA or AAA, with one-h1 and page-title (the host page's) and contrast and justified-text (which read inline styles, and no adapter writes one) off; every surface it refuses for its words, drawn anyway, fails runChecks's rule for the same words; and the word lists the two share are compared by their text. The validator is the stricter of the two: it refuses "Read more" as a link, which runChecks only asks about at AAA, and placeholder words without brackets. With two adapters broken on purpose, the same run fails (test/agreement-broken.test.ts).
The catalogue's id, https://ui.setsquare.dev/agent/catalog/v1.json, is its address: the docs site serves it there, from this package, as JSON any site may read.
In a chat host: the MCP App
./mcp-app shows surfaces in a host that supports MCP Apps (SEP-1865, the stable version of 2026-01-26), such as Claude: the model calls a tool whose input is A2UI messages, and the host draws the result in a sandboxed iframe. examples/mcp-server.mjs is the smallest server, on the official SDK:
import { McpServer, fromJsonSchema } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import {
actionResult,
actionTool,
showSurface,
surfaceInstructions,
surfaceResource,
surfaceTool,
} from '@setsquare/ui-agent/mcp-app';
const options = { links: { origins: ['https://help.example.com'] } }; // the server's check, and the view's
serveStdio(() => {
// When to use the tool, which a host gives the model before it has loaded the tool itself.
const server = new McpServer({ name: 'surfaces', version: '1.0.0' }, { instructions: surfaceInstructions() });
const view = surfaceResource({ validate: options });
server.registerResource(view.name, view.uri, { mimeType: view.mimeType }, async () => view.read());
// The model's tool: its description carries the rules, the options in words and the catalogue's short form.
const show = surfaceTool({ resourceUri: view.uri, options });
server.registerTool(
show.name,
{ description: show.description, inputSchema: fromJsonSchema(show.inputSchema), _meta: show._meta },
async (args) => showSurface(args, options),
);
// The view's tool, hidden from the model: the person's action, answered with messages for the surface.
const act = actionTool({ resourceUri: view.uri });
server.registerTool(
act.name,
{ description: act.description, inputSchema: fromJsonSchema(act.inputSchema), _meta: act._meta },
async ({ message }) =>
actionResult('Saved.', [{ version: 'v0.9.1', deleteSurface: { surfaceId: message.action.surfaceId } }]),
);
return server;
});- The model is told when to use it. A host may show a model only a tool's name until the model loads it, and the Claude app did. So the tool is
show_form_or_approvalby default, a name that says what it's for, andsurfaceInstructions()is the server'sinstructions, which a host gives the model from the start: when the person should fill in, choose, approve or confirm something, or see a table or figures, use the tool rather than asking in words or with a built-in question tool. Without them, on 2026-10-21, Claude's model asked with its own question tool instead. The description's first 80 or so characters, all a host may show before the model loads the tool, say when to use it too. - The server checks first.
showSurfacevalidates in the tool's handler and returns A2UI's errors as the tool's error result, so the model repairs its description before anything reaches the person. The view checks again. - The view is one HTML document with React, the renderer and its styles inline, which a host's default CSP allows: no
'unsafe-eval', no network, no font source. It uses the system's fonts unless the server passesfontCssand lists the font's origin in the resource'scsp.resourceDomains. On 2026-10-21 it was 1,075 KB of HTML, 295 KB gzipped, most of it React DOM and React Aria with its words in every language it ships.dist/mcp-app/LICENSES.txtlists the 18 packages bundled into it, with their licences. - The product's logo is
logoin the view's config: adata:image/…URL, drawn from the HTML itself, or an https address, whose originsurfaceResourceadds tocsp.resourceDomainsso the host lets it load. Anything else is refused. - From the host it takes the theme, which sets
dark, and the locale, which picks the words fromlabelsand sets the page'slanganddir. The level is the product's (conformance: 'aaa'): a host has no such setting. The host's colours and fonts aren't used, since the contrast audit covers Setsquare's tokens and not a host's. - The product's brand is
brand: itsbrandandbrand-subtle,#rrggbb, inlight,dark,light-aaaanddark-aaa. Before the view is drawn in them they're measured against every pair the contrast audit promises them in, 19 to 24 a theme: the brand on the page, the primary button's words on it, the focus ring, the selected fill. A brand that doesn't reach one is refused when the resource is declared, as the server starts, naming each pair, its theme and its ratio;checkBrand(brand)gives the same answer ahead of time. The rest of the colours stay Setsquare's. - Only the complete input is drawn. The partial arguments a host may stream can be repaired by it and haven't been through the server's check, so until the complete input comes the view shows its loading state.
- The person's action goes into the model's context for its next turn (
ui/update-model-context) and to the server's action tool (tools/call), each where the host supports it. What the tool returns may carry A2UI messages for the surface:deleteSurfaceleaves the receipt. - The view reports its size as it changes, answers
ui/resource-teardown, and draws nothing once the host cancels the call. A host removes the view whenever it likes, so in a chat host a surface stays only as long as the host keeps it. - Forms work without
allow-forms. A host may sandbox the view without it, and the browser then blocks a form's own submission. So the renderer never asks the browser to submit: it checks the fields itself and, when they pass, sends the action.
Try it in Claude Desktop, which runs a local server over stdio. The example gives the view Setsquare's mark and its palette's blue, as a product would give its own. After pnpm build, open Claude's Settings (from the menu bar's Claude menu), then Developer, then Edit Config, and add the server under mcpServers, beside whatever the file already holds, with absolute paths, since the app doesn't use your shell's PATH:
"mcpServers": {
"setsquare-surfaces": {
"command": "/absolute/path/to/node",
"args": ["/absolute/path/to/setsquare-ui/packages/ui-agent/examples/mcp-server.mjs"]
}
}Quit Claude and open it again, then ask for something a surface suits: "Ask me for the supplier's missing VAT number and contact email." ~/Library/Logs/Claude/mcp-server-setsquare-surfaces.log has the server's errors. claude.ai on the web needs a server it can reach over HTTP instead (a tunnel and a custom connector), which the example doesn't offer.
Tested: the declarations and the handler in Node; the example server over stdio, as a host starts it (test/mcp-server.test.ts); and the view in Chromium inside a fake host on another origin, served with MCP Apps' default CSP word for word, in the sandbox the reference host gives its view and in one without allow-forms (test/browser/agent-mcp-app.mjs): the handshake, the theme and locale, partial input never drawn, positioning and targets in the frame, the size reported, the action, the receipt, a form's checks and its saving without allow-forms, teardown, a refused description drawn nowhere, and the CSP refusing the page's own eval. A real host, the Claude app, has been tried by hand, which isn't a test; the proposal's phase 6 records what was seen.
Measuring a model against it
pnpm eval:agent (in the repository, not the package) gives a real model the catalogue, its rules and ten tasks, from an approval to a form in French. Each reply is checked; a refused one goes back with its errors, up to twice. It reports how many were valid the first time, the repairs the rest needed, the rules broken first, and what the prompt costs in tokens with and without the catalogue. --model picks the model (default claude-opus-5-5); --names basic gives Checkbox and DatePicker A2UI's basic catalogue's names to compare. It needs a network, and either ANTHROPIC_API_KEY (--via api) or Claude Code signed in on the machine (--via claude, the default without a key), which runs each task as one claude -p conversation on that login, with its tools, MCP servers and user settings off, in an empty folder. Claude Code adds some context of its own, so the token count is taken with and without the catalogue and the difference is the catalogue's. It isn't part of ci:local, and its numbers vary from run to run, so they're recorded in the proposal with the model, the route and the date rather than called tested. Its loop, and the Claude Code route against a stand-in claude, are tested offline.
pnpm eval:host measures what that doesn't: the MCP App as a chat host uses it. It runs Claude Code's print mode with its own system prompt and tools and the example server attached, and gives it sixteen requests as a person types them, with no props or action names in them. Claude Code shows the model the tool's name and loads the rest when asked, as the Claude app does. For each it reports whether the model used the tool, whether its first description was shown or refused, the rules each refusal named, and every refusal's words. Print mode has no question tool of its own, so "not used" there means the model answered in words. Each request is a whole session on Claude Code's login, so a run costs far more than eval:agent's.
Install
Once it's published:
pnpm add @setsquare/ui-agent @setsquare/uiIt needs React 19.2 or later and Tailwind CSS 4 in the app. In the app's global stylesheet, after @setsquare/ui's:
@import "tailwindcss";
@import "@setsquare/ui/styles.css";
@import "@setsquare/ui-agent/styles.css";styles.css has an @source that points Tailwind at the package's compiled components.
Size
pnpm --filter @setsquare/ui-agent size measures each entry as ui-flows, ui-blocks and ui-motion were measured: bundled with esbuild, minified, React, React Aria and @setsquare/ui left out, then gzip -9. On 2026-10-21, . (the renderer, with the validator it runs on) was 67,551 bytes minified and 22,933 gzipped, and ./catalog alone 44,587 and 15,290. The catalogue's JSON, as it goes in a prompt, is about 47 KB.
Licence
Apache-2.0, like @setsquare/ui and the other add-ons, until v1.0.0. A version published under it stays under it. See LICENSE and NOTICE.
