Skip to content

How to Define a Catalog

Define a custom A2UI catalog by writing a catalog.json schema file, loading it on your agent, and registering matching UI widgets on your client app under the same catalogId.

When you need a catalog.json file

If your agent and client share the same language and repository, you can define component schemas directly in code, as shown in Catalogs. Write a standalone catalog.json file when your backend agent and client app use different languages (for example, a Python agent serving a Flutter, React, SwiftUI, or Jetpack Compose app) or when you publish a reusable catalog for other teams.

In A2UI v1.0, your catalog works alongside the built-in basic catalog, so you keep standard layout widgets (Column, Row, Text, Button) and define only the custom widgets unique to your app. See Catalogs for how catalog negotiation works at runtime.

Step 1: Create a minimal catalog.json

Create a file named status_catalog.json that defines a single StatusBadge component with two properties: a bindable label string and a status tone.

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/catalogs/status/v1/catalog.json",
"catalogId": "https://example.com/catalogs/status/v1/catalog.json",
"protocolVersion": "1.0",
"title": "Status Catalog",
"components": {
"StatusBadge": {
"type": "object",
"description": "Displays a short status pill.",
"properties": {
"component": {"const": "StatusBadge"},
"label": {"$ref": "common_types.json#/$defs/DynamicString"},
"status": {
"type": "string",
"enum": ["info", "success", "warning"]
}
},
"required": ["component", "label", "status"]
}
},
"$defs": {
"anyComponent": {
"oneOf": [{"$ref": "#/components/StatusBadge"}],
"discriminator": {"propertyName": "component"}
},
"anyFunction": {"not": {}}
}
}
FieldPurpose
catalogIdUnique URI shared by the agent and client renderer. Version the URI path (v1/, v2/) when making breaking changes.
protocolVersionWire version ("1.0", without a leading v) validated against catalog_definition.json. Omitted in v0.9.1 standalone files.
componentsMap of component schemas. Referencing DynamicString lets label accept either literal text or a data-model binding ({"@path": "/statusText"} in v1.0; {"path": "/statusText"} in v0.9.1). In v0.9.1, components also compose ComponentCommon.
$defsDeclares anyComponent (union of all components) and anyFunction ({"not": {}} when the catalog defines no custom functions).

Step 2: Load the catalog on your agent

Load status_catalog.json with Catalog.fromJson (Catalog.from_json in Python) and pass it to A2uiGenerator alongside the basic catalog. A2uiGenerator automatically includes your component schema in the LLM prompt and validates generated messages against it.

import {readFileSync} from 'node:fs';
import {A2uiGenerator, CatalogConfig, ExpressFormatFactory} from '@a2ui/agent';
import {Catalog} from '@a2ui/web_core';
import {basicCatalog} from '@a2ui/web_core/v1_0';
const statusCatalog = Catalog.fromJson(JSON.parse(readFileSync('./status_catalog.json', 'utf8')));
export const generator = new A2uiGenerator(
[new CatalogConfig(basicCatalog), new CatalogConfig(statusCatalog)],
undefined,
new ExpressFormatFactory(),
);

Swift (A2UISwiftCore and A2UISwiftUI) and Kotlin (androidx.a2ui and androidx.a2ui.compose) are client-side renderer SDKs and do not include a server-side agent generator; see How to Integrate A2UI into an Agent for full backend setup.

Step 3: Register the widget on your client

Implement StatusBadge in your UI framework, wrap it in a Catalog with the same catalogId (https://example.com/catalogs/status/v1/catalog.json), and register it alongside the basic catalog in MessageProcessor (or provideA2UI in Angular). Once this minimal widget works end to end, see Child slots and layout constraints for how nested child slots and interactive actions map to widget code.

import 'package:a2ui_core/a2ui_core.dart';
import 'package:a2ui_flutter/a2ui_flutter.dart';
import 'package:a2ui_flutter/basic_catalog.dart';
import 'package:flutter/material.dart';
import 'package:json_schema_builder/json_schema_builder.dart';
final statusBadge = ComponentImplementation(
name: 'StatusBadge',
schema: Schema.object(
properties: {
'component': Schema.string(enumValues: ['StatusBadge']),
'label': CommonSchemasV1.dynamicString,
'status': Schema.string(enumValues: ['info', 'success', 'warning']),
},
required: ['component', 'label', 'status'],
),
builder: (context, node, props, buildChild) {
final String label = props.string('label') ?? '';
final String status = props.string('status') ?? 'info';
return Chip(label: Text('$status: $label'));
},
);
final statusCatalog = Catalog<ComponentImplementation, FunctionImplementation>(
id: 'https://example.com/catalogs/status/v1/catalog.json',
protocolVersion: A2uiProtocolVersion.v1_0,
components: [statusBadge],
);
final processor = MessageProcessor(catalogs: [basicCatalog(), statusCatalog]);

Once registered on both sides, your agent can combine basic layout components and StatusBadge on the same v1.0 surface by tagging the custom component with its catalogId (in v0.9.1, each surface binds to a single catalogId):

{
"version": "1.0",
"createSurface": {
"surfaceId": "deploy-card",
"catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json",
"components": [
{
"id": "root",
"component": "Column",
"children": ["heading", "build_badge"]
},
{
"id": "heading",
"component": "Text",
"text": "Release pipeline"
},
{
"id": "build_badge",
"catalogId": "https://example.com/catalogs/status/v1/catalog.json",
"component": "StatusBadge",
"label": "All checks passing",
"status": "success"
}
]
}
}

Advanced catalog features

Custom functions and input validators

A catalog’s functions map exposes custom client capabilities to generated surfaces without requiring a server round-trip:

Capabilityv1.0 returnType and callersTypical use
Device-local data and state"string", "number", "boolean", or "object" ("rendererOnly")Reading device timezone, locale, or platform capabilities that are not stored on the server.
Device-local native actions"void" ("rendererOnly", "requiresUserActivation": true)Triggering native share sheets, clipboard writes, or deep links from a user-initiated Action.
Local formatting & computation"string", "number", "array", or "any" ("rendererOrAgent")Computing derived values reactively from bound data-model fields.
Input validation (checks)"validationResult" ("rendererOnly"; "boolean" in v0.9.1)Validating user input synchronously and returning {"valid": false, "message": "..."} in v1.0.

In v1.0, each FunctionDefinition declares returnType at the top level of the function object and uses "@call" inside properties. You can set allowedCallers to "rendererOnly" (default), "agentOnly", or "rendererOrAgent". Side-effecting device functions can set "requiresUserActivation": true so they run only from direct user interactions; functions that require user activation must keep allowedCallers set to "rendererOnly" (or omit it).

In v0.9.1 standalone files, functions place "call" and "returnType": {"const": "..."} inside properties, and validation rules return "boolean". When a v0.9.1 renderer advertises inline catalogs in a2uiClientCapabilities, MessageProcessor converts the functions map into the array format defined by client_capabilities.json.

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/catalogs/pricing/v1/catalog.json",
"catalogId": "https://example.com/catalogs/pricing/v1/catalog.json",
"protocolVersion": "1.0",
"functions": {
"shareProduct": {
"type": "object",
"description": "Opens the device share sheet for a product link.",
"returnType": "void",
"allowedCallers": "rendererOnly",
"requiresUserActivation": true,
"properties": {
"@call": {"const": "shareProduct"},
"args": {
"type": "object",
"properties": {
"title": {"$ref": "common_types.json#/$defs/DynamicString"},
"url": {"$ref": "common_types.json#/$defs/DynamicString"}
},
"required": ["title", "url"],
"unevaluatedProperties": false
}
},
"required": ["@call", "args"]
},
"validDiscount": {
"type": "object",
"description": "Checks that a discount percentage is between 0 and 70.",
"returnType": "validationResult",
"allowedCallers": "rendererOnly",
"properties": {
"@call": {"const": "validDiscount"},
"args": {
"type": "object",
"properties": {
"percentOff": {"$ref": "common_types.json#/$defs/DynamicNumber"}
},
"required": ["percentOff"],
"unevaluatedProperties": false
}
},
"required": ["@call", "args"]
}
},
"$defs": {
"anyComponent": {"not": {}},
"anyFunction": {
"oneOf": [{"$ref": "#/functions/shareProduct"}, {"$ref": "#/functions/validDiscount"}]
}
}
}

On the client, register matching function implementations (FunctionImplementation or A2uiFunction) on your catalog:

import {z} from 'zod';
import {
Catalog,
DynamicNumberSchema,
DynamicStringSchema,
createFunctionImplementation,
} from '@a2ui/web_core/v1_0';
const shareProductFn = createFunctionImplementation(
{
name: 'shareProduct',
returnType: 'void',
allowedCallers: 'rendererOnly',
requiresUserActivation: true,
schema: z.object({title: DynamicStringSchema, url: DynamicStringSchema}),
},
({title, url}) => {
void navigator.share?.({title: String(title), url: String(url)});
},
);
const validDiscountFn = createFunctionImplementation(
{
name: 'validDiscount',
returnType: 'validationResult',
allowedCallers: 'rendererOnly',
schema: z.object({percentOff: DynamicNumberSchema}),
},
({percentOff}) => {
const pct = Number(percentOff);
return pct >= 0 && pct <= 70
? {valid: true}
: {valid: false, message: 'Discount must be between 0% and 70%.'};
},
);
export const pricingCatalog = new Catalog(
'https://example.com/catalogs/pricing/v1/catalog.json',
'1.0',
[],
[shareProductFn, validDiscountFn],
);

Child slots and layout constraints

Components that host nested widgets reference ChildList (an array of child IDs or a data-bound collection template) or ComponentId (a single child slot).

In v1.0, a ComponentDefinition can also constrain placement with allowedParents (for example, ["Column", "List"], or ["Surface"] for root-only modals) and allowedChildren (["Button"]). "Surface" is reserved and cannot be used as a component name, and v1.0 identifiers must follow Unicode UAX #31 without a leading @.

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/catalogs/shop/v1/catalog.json",
"catalogId": "https://example.com/catalogs/shop/v1/catalog.json",
"protocolVersion": "1.0",
"components": {
"ProductCard": {
"type": "object",
"allowedParents": ["Column", "List"],
"allowedChildren": ["Button"],
"properties": {
"component": {"const": "ProductCard"},
"name": {"$ref": "common_types.json#/$defs/DynamicString"},
"price": {"$ref": "common_types.json#/$defs/DynamicNumber"},
"children": {"$ref": "common_types.json#/$defs/ChildList"},
"onSelect": {"$ref": "common_types.json#/$defs/Action"}
},
"required": ["component", "name", "price"]
}
},
"$defs": {
"anyComponent": {
"oneOf": [{"$ref": "#/components/ProductCard"}],
"discriminator": {"propertyName": "component"}
},
"anyFunction": {"not": {}}
}
}

In your client renderer, ChildList slots resolve to child references that you render through the framework adapter (buildChild, this.renderNode, ComponentHostComponent, ComponentNodeView, or observeA2uiComponentState with A2uiComponent), while Action properties trigger event or function execution (dispatchAction in Jetpack Compose):

import 'package:a2ui_core/a2ui_core.dart';
import 'package:a2ui_flutter/a2ui_flutter.dart';
import 'package:flutter/material.dart';
import 'package:json_schema_builder/json_schema_builder.dart';
final productCard = ComponentImplementation(
name: 'ProductCard',
allowedParents: ['Column', 'List'],
allowedChildren: ['Button'],
schema: Schema.object(
properties: {
'component': Schema.string(enumValues: ['ProductCard']),
'name': CommonSchemasV1.dynamicString,
'price': CommonSchemasV1.dynamicNumber,
'children': CommonSchemas.childList,
'onSelect': CommonSchemas.action,
},
required: ['component', 'name', 'price'],
),
builder: (context, node, props, buildChild) {
final String name = props.string('name') ?? '';
final num price = props.number('price') ?? 0;
final VoidCallback? onSelect = props.isValid ? props.action('onSelect') : null;
final List<Widget> children = [
for (final child in props.children('children')) buildChild(child),
];
return Card(
child: InkWell(
onTap: onSelect,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$name (USD $price)'),
Row(children: children),
],
),
),
);
},
);

Interactive actions and input checks

Reference Action on interactive properties to let surfaces dispatch server events or run local functions (such as shareProduct). When an input component supports client-side validation rules (checks), compose Checkable via allOf so the renderer evaluates bound CheckRule conditions and exposes isValid and validationErrors to your widget.

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/catalogs/checkout/v1/catalog.json",
"catalogId": "https://example.com/catalogs/checkout/v1/catalog.json",
"protocolVersion": "1.0",
"components": {
"DiscountInput": {
"type": "object",
"allOf": [{"$ref": "common_types.json#/$defs/Checkable"}],
"properties": {
"component": {"const": "DiscountInput"},
"label": {"$ref": "common_types.json#/$defs/DynamicString"},
"percentOff": {"$ref": "common_types.json#/$defs/DynamicNumber"},
"onApply": {"$ref": "common_types.json#/$defs/Action"}
},
"required": ["component", "label", "percentOff"]
}
},
"$defs": {
"anyComponent": {
"oneOf": [{"$ref": "#/components/DiscountInput"}],
"discriminator": {"propertyName": "component"}
},
"anyFunction": {"not": {}}
}
}

Prompt instructions and catalog metadata

In v1.0, top-level instructions holds Markdown guidance that A2uiGenerator appends to the LLM system prompt whenever the catalog is active. You can also attach custom key-value pairs under metadata.extensions (keys must follow Unicode UAX #31, and keys starting with a2ui_ are reserved for official protocol extensions). In v0.9.1, instructions and metadata are not top-level catalog fields; place prompt guidance in component and property description strings instead.

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/catalogs/shop/v1/catalog.json",
"catalogId": "https://example.com/catalogs/shop/v1/catalog.json",
"protocolVersion": "1.0",
"instructions": "Place `ProductCard` inside `Column` or `List` and pass `Button` IDs to `children`.",
"metadata": {
"extensions": {
"storefrontOwner": "commerce-ui"
}
},
"components": {},
"$defs": {
"anyComponent": {"not": {}},
"anyFunction": {"not": {}}
}
}

Reusable schema definitions ($defs) and theming

Place shared scalar or enum schemas (such as BadgeTone) under $defs alongside anyComponent and anyFunction. Under the v1.0 specification, a catalog is self-contained: $ref targets inside a catalog may point only to local #/$defs/..., #/components/..., or #/functions/... entries and allowlisted common_types.json#/$defs/... definitions, never to an external catalog.json. In v0.9.1, you can also declare $defs.theme to schema-validate catalog-level theme tokens.

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/catalogs/shop/v1/catalog.json",
"catalogId": "https://example.com/catalogs/shop/v1/catalog.json",
"protocolVersion": "1.0",
"components": {
"PromoBanner": {
"type": "object",
"properties": {
"component": {"const": "PromoBanner"},
"headline": {"$ref": "common_types.json#/$defs/DynamicString"},
"tone": {"$ref": "#/$defs/BadgeTone"}
},
"required": ["component", "headline"]
}
},
"$defs": {
"BadgeTone": {
"type": "string",
"enum": ["sale", "new", "limited"]
},
"anyComponent": {
"oneOf": [{"$ref": "#/components/PromoBanner"}],
"discriminator": {"propertyName": "component"}
},
"anyFunction": {"not": {}}
}
}

Schema parity tests in CI

When your backend agent loads status_catalog.json and your client defines schemas in code (with Zod, json_schema_builder, JSONSchema, or A2uiProperty and A2uiSchema), avoid comparing serialized JSON files as raw strings. Code-built schemas and hand-authored JSON files often differ in key order, $schema or instructions fields, and localized #/$defs/... pointers inside validationSchema.

Instead, parse status_catalog.json in a unit test with Catalog.fromJson (TypeScript and Dart), Catalog.from_json (Python), JSONSerialization (Swift), or kotlinx.serialization.json.Json (Kotlin), and compare the normalized catalog properties (id, protocolVersion, component names and allowedParents/allowedChildren, and function returnType and allowedCallers) against your client catalog.

import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {test} from 'node:test';
import {Catalog} from '@a2ui/web_core';
import {statusCatalog} from './status_catalog.js';
test('client statusCatalog matches status_catalog.json', () => {
const specCatalog = Catalog.fromJson(JSON.parse(readFileSync('./status_catalog.json', 'utf8')));
assert.equal(statusCatalog.id, specCatalog.id);
assert.equal(statusCatalog.protocolVersion, specCatalog.protocolVersion);
assert.deepEqual(
[...statusCatalog.components.keys()].sort(),
[...specCatalog.components.keys()].sort(),
);
assert.deepEqual(
[...statusCatalog.functions.keys()].sort(),
[...specCatalog.functions.keys()].sort(),
);
for (const [name, specComp] of specCatalog.components) {
const clientComp = statusCatalog.components.get(name)!;
assert.deepEqual(clientComp.allowedParents, specComp.allowedParents);
assert.deepEqual(clientComp.allowedChildren, specComp.allowedChildren);
}
for (const [name, specFn] of specCatalog.functions) {
const clientFn = statusCatalog.functions.get(name)!;
assert.equal(clientFn.returnType, specFn.returnType);
assert.equal(clientFn.allowedCallers, specFn.allowedCallers);
}
});