Domänenspezifische Widgets als gleichwertige Bausteine für das Sprachmodell.
Sobald fachliche Konzepte ins Spiel kommen, reicht der Basic Catalog von A2UI selten aus: Eine Flugbuchung, eine Boardkarte oder ein Bonusprogramm wirken als generische Karten und Listen schnell beliebig. Mit Custom Catalogs sieht A2UI deshalb einen klaren Mechanismus vor, um dem Sprachmodell eigene, fachlich passende Komponenten und Funktionen zur Verfügung zu stellen, ohne den schlanken, deklarativen Charakter des Protokolls aufzugeben.
Dieser dritte und abschließende Teil der Serie zeigt, wie sich solche Custom Catalogs in Angular definieren, beim Renderer registrieren und in die im zweiten Teil vorgestellte CopilotKit-Anbindung integrieren lassen.
📂 Source Code (siehe Branch copilotkit; das eigenständige Renderer-Beispiel findet sich im Ordner projects/a2ui-demo)
Was sind Custom Catalogs in A2UI?
Ein Custom Catalog erweitert A2UI um eigene, fachlich getriebene Komponenten und Funktionen, die das Sprachmodell wie jede andere Komponente referenzieren darf. Häufig stellen Custom Catalogs eine Übermenge des Basic Catalog dar, sodass sie neben den eigenen auch die allgemein bekannten UI-Bausteine enthalten. Aus Sicht des Renderers bleibt die Verarbeitung gleich; das LLM erhält lediglich einen größeren Werkzeugkasten.
Im weiteren Verlauf ergänzen wir die im ersten Teil vorgestellte Passagier-Karte um eine eigene MilesProgress-Komponente, die den Fortschritt zur nächsten Bonusstufe visualisiert:

Custom Catalogs in A2UI: Eigene Komponenten für KI-generierte UIs
Eine A2UI-Komponente in Angular ist im Kern eine ganz normale Angular-Komponente. Sie erhält jedoch über ein definiertes Context-Objekt die Inputs, die der Agent über A2UI übermittelt. Das folgende Listing zeigt die Definition eines solchen Kontextes für unsere MilesProgress-Komponente. Die Eigenschaft passenger ist dabei als BoundProperty deklariert: Sie kann sowohl konkrete Daten aufnehmen als auch eine Bindung an das Datenmodell repräsentieren:
import type { BoundProperty } from '@a2ui/angular/v0_9';
export interface MilesProgressContext {
passenger: BoundProperty<Passenger>;
}
Die zugehörige Komponente empfängt diesen Kontext über das InputSignal props:
@Component({
selector: 'app-miles-progress',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DecimalPipe],
template: `
<section class="miles-progress">
<p class="eyebrow">Miles Progress</p>
<p class="current">{{ passenger().bonusMiles | number }}</p>
<p class="remaining">
{{ remainingMiles() | number }} miles to {{ nextThreshold() | number }}
</p>
<div aria-hidden="true" class="track">
<div class="fill" [style.width.%]="progressPercent()"></div>
</div>
</section>
`,
styleUrl: './miles-progress.css',
})
export class MilesProgress {
readonly props = input<MilesProgressContext>(initialContext);
readonly surfaceId = input.required<string>();
readonly componentId = input.required<string>();
readonly dataContextPath = input('/');
protected readonly passenger = computed(() => this.props().passenger.value());
protected readonly nextThreshold = computed(() =>
calcNextThreshold(this.passenger().bonusMiles),
);
protected readonly remainingMiles = computed(() =>
calcRemainingMiles(this.nextThreshold(), this.passenger().bonusMiles),
);
protected readonly progressPercent = computed(() =>
calcProgressPercent(this.nextThreshold(), this.passenger().bonusMiles),
);
}
Neben props setzt der Renderer drei weitere Inputs, wenn er die Komponente dynamisch erzeugt: surfaceId benennt die Surface, zu der die Komponente gehört, componentId identifiziert sie innerhalb dieser Surface, und dataContextPath gibt den Basis-Pfad im Datenmodell an, gegen den relative Bindings aufgelöst werden. Unsere MilesProgress-Komponente braucht diese Angaben nicht; interessant werden sie für Widgets, die selbst ins Datenmodell schreiben oder Aktionen auslösen.
Ein eigenes Interface für diesen Satz an Inputs gibt es derzeit nicht.
Die gezeigte MilesProgress-Komponente liest die aktuellen Bonusmeilen aus dem Kontext und berechnet mithilfe eines computed Signals die Anzahl der Meilen, die noch bis zur Erreichung der nächsten Bonusstufe fehlen. Außerdem zeigt sie eine Fortschrittsanzeige für dieses Ziel an.
Damit der Renderer die MilesProgress-Komponente kennt und korrekt instanziieren kann, ist außerdem eine Beschreibung ihrer Inputs in Form eines Schemas erforderlich. Der Renderer vom A2UI-Team nutzt hierfür die populäre Bibliothek Zod:
import type { AngularComponentImplementation } from '@a2ui/angular/v0_9';
import { z } from 'zod/v3';
[...]
const passengerSchema = z.object({
id: z.number(),
firstName: z.string(),
lastName: z.string(),
bonusMiles: z.number(),
});
const milesProgressSchema = z
.object({
passenger: binding(passengerSchema).optional(),
})
.strict();
export const milesProgressEntry = {
name: 'MilesProgress',
component: MilesProgress,
schema: milesProgressSchema as unknown,
} as unknown as AngularComponentImplementation;
// ^^^ Cast umgeht ein Typing-Problem in der aktuellen Version
Das Schema legt fest, dass die Eigenschaft passenger entweder als Objekt (passengerSchema) oder als Datenbindung mit der Eigenschaft path übergeben werden darf. Die Konstante milesProgressEntry fasst den Namen und die Implementierung der Komponente sowie das Schema zu einer Einheit zusammen, die der Katalog später aufnehmen kann.
Da jede Eigenschaft entweder einen konkreten Wert oder eine Datenbindung aufnehmen darf, bringt das Beispielprojekt eine kleine Hilfsfunktion binding mit, die einem Wert-Schema die Alternative eines path-Bindings zur Seite stellt:
export function binding<T extends z.ZodTypeAny>(schema: T) {
return z.union([schema, z.object({ path: z.string() }).strict()]);
}
Neu: Agentic UI with Angular
Wenn du A2UI nicht nur integrieren, sondern sauber in größere Architekturen einbetten willst:
In meinem Buch Agentic UI mit Angular gehe ich genau auf diese Patterns und Trade-offs im Detail ein.
Eigene Funktionen für den Custom Catalog
Neben Komponenten kann ein Custom Catalog auch eigene Funktionen bereitstellen. Diese ergänzen die im Basic Catalog enthaltenen Standardfunktionen wie formatNumber und formatDate. Das folgende Listing zeigt eine kleine Hilfsfunktion formatId, die eine numerische ID in eine sprechende Zeichenkette wie P-0042 umwandelt. Die Factory createFunctionImplementation aus @a2ui/web_core nimmt dazu die Metadaten – Name, Rückgabetyp und ein Zod-Schema für die erwarteten Argumente – sowie die eigentliche Implementierung entgegen:
import {
createFunctionImplementation,
type FunctionImplementation,
} from '@a2ui/web_core/v0_9';
import { z } from 'zod/v3';
const formatIdSchema = z
.object({
value: z.number(),
})
.strict();
export const formatIdImplementation = createFunctionImplementation(
{
name: 'formatId',
returnType: 'string',
schema: formatIdSchema as unknown as FunctionImplementation['schema'],
},
({ value }) => {
const normalizedValue = Math.max(0, Math.trunc(value));
return `P-${String(normalizedValue).padStart(4, '0')}`;
},
);
Mit Komponenten und Funktionen sind die beiden zentralen Bausteine eines Custom Catalogs definiert. Im nächsten Schritt müssen wir den Katalog für den Renderer verfügbar machen.
Custom Catalog beim A2UI-Renderer registrieren
Ein Custom Catalog ist eine Instanz von BasicCatalogBase, die im Konstruktor eine eindeutige Id, die Liste der zusätzlichen Komponenten sowie eine Liste mit Funktionen erhält:
import { BASIC_FUNCTIONS, BasicCatalogBase } from '@a2ui/angular/v0_9';
import { formatIdImplementation } from './format-id';
import { milesProgressEntry } from './miles-progress';
export const customCatalog = new BasicCatalogBase({
id: 'https://example.com/catalogs/flights42-a2ui-demo',
extraComponents: [milesProgressEntry],
functions: [...BASIC_FUNCTIONS, formatIdImplementation],
});
Die API verhält sich hier in der betrachteten Version leider etwas asymmetrisch: extraComponents ergänzt die Standardkomponenten, während functions die Standardfunktionen ersetzt und BASIC_FUNCTIONS daher von Hand hineinzuspreizen ist.
Damit der Renderer den neuen Katalog nutzt, muss die Instanz nur noch in der Konfiguration referenziert werden – ein eigener Angular-Service ist dafür nicht nötig:
import {
A2UI_RENDERER_CONFIG,
A2uiRendererService,
provideMarkdownRenderer,
} from '@a2ui/angular/v0_9';
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { marked } from 'marked';
import { customCatalog } from './custom-catalog/custom-catalog';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
{
provide: A2UI_RENDERER_CONFIG,
useValue: {
catalogs: [customCatalog],
},
},
provideMarkdownRenderer(async (markdown) =>
marked.parse(String(markdown ?? '')),
),
A2uiRendererService,
],
};
Sobald der Katalog registriert ist, kann die Beispielanwendung die MilesProgress-Komponente wie jede andere Komponente in einer A2UI-Nachricht verwenden. Das folgende Listing zeigt einen Ausschnitt einer updateComponents-Nachricht, in der die MilesProgress-Komponente neben der bestehenden Passagier-Karte angezeigt wird:
updateComponents: {
surfaceId,
components: [
{
id: 'root',
component: 'Column',
children: ['passenger-card', 'miles-progress'],
},
[...]
{
id: 'miles-progress',
component: 'MilesProgress',
passenger: { path: '/passenger' },
},
],
}
Custom Components in der CopilotKit-Anbindung einbinden
Bisher haben wir Custom Components für den eigenständigen A2UI-Renderer registriert. In Kombination mit der im zweiten Teil vorgestellten CopilotKit-Anbindung sieht das Vorgehen ähnlich, aber etwas komfortabler aus: Das Beispielprojekt bietet im Ordner util-copilotkit eigene Hilfsfunktionen, die sowohl die Komponenten-Beschreibung als auch deren Bereitstellung kapseln. Wie diese Hilfsfunktionen intern aufgebaut sind, zeigt der Abschnitt „Unter der Haube: Die Schema-Helfer im Detail" am Ende des Artikels.
Damit der A2UI-Renderer im Sidecar nicht nur die Komponenten des Basic Catalogs, sondern auch eigene Widgets anzeigen kann, lassen sich Custom Components ergänzen. Im hier verwendeten Beispielprojekt findet sich ein TicketWidget, das eine Boardkarte darstellt:

Die dabei genutzte Hilfsfunktion createCustomComponent nimmt den Namen, die Beschreibung, die Komponenten-Implementierung sowie ein Zod-Schema entgegen, das die Eigenschaften der Komponente beschreibt:
import { z } from 'zod/v3';
import {
binding,
createCustomComponent,
} from '../../../shared/util-copilotkit/a2ui/a2ui-schema';
import { A2uiCustomCatalogComponent } from '../../../shared/util-copilotkit/a2ui/types';
import { TicketWidget } from './ticket/ticket-widget';
export const ticketWidgetEntry = createCustomComponent({
name: 'TicketWidget',
description: 'A boarding-pass-style widget ...',
component: TicketWidget,
schema: z
.object({
ticketId: binding(z.union([z.string(), z.number()])),
from: binding(z.string()),
to: binding(z.string()),
date: binding(z.string()),
delay: binding(z.number()).optional(),
})
.strict(),
});
export const ticketingExtraComponents: A2uiCustomCatalogComponent[] = [
ticketWidgetEntry,
];
Die Hilfsfunktion binding entspricht der weiter oben gezeigten Variante: Sie kennzeichnet jene Felder, die das LLM entweder direkt setzen oder über ein path-Binding an Werte aus dem Datenmodell knüpfen darf. Zusätzlich stellt createCustomComponent über seine Typparameter sicher, dass das props-Signal der übergebenen Komponente zum Schema passt.
Ein Blick auf die vollständige description im Beispielprojekt lohnt sich übrigens: Sie beschreibt nicht nur, was das Widget darstellt, sondern gibt dem Sprachmodell auch klare Nutzungsregeln mit – etwa, dass es das TicketWidget nur auf explizite Nachfrage und höchstens einmal pro Anfrage einsetzen soll. Die Beschreibung wird damit zum Baustein des Prompts.
Die Hilfsfunktion createCustomCatalog fasst die Komponenten und die Katalog-Id zu einem Katalog-Deskriptor zusammen:
import { createCustomCatalog } from '../../../shared/util-copilotkit/a2ui/types';
import { ticketingExtraComponents } from './ticketing-extra-components';
export const customCatalog = createCustomCatalog({
id: 'https://example.com/catalogs/flights42-a2ui-demo',
components: ticketingExtraComponents,
});
Als Id vergibt das Beispielprojekt – wie bei A2UI-Katalogen üblich – eine URL aus dem eigenen Hoheitsgebiet, um Eindeutigkeit zu gewährleisten. Diese Id ist dabei mehr als ein Etikett: Der Renderer schlägt den Katalog anhand der Id nach, und der Agent muss genau diese Id in seinen createSurface-Operationen verwenden. Damit das funktioniert, übermittelt der Client die Id gemeinsam mit der Katalog-Beschreibung an den Agent; wie sie dort in den Systemprompt einfließt, zeigen die nächsten Abschnitte.
Für die Registrierung genügt die aus dem zweiten Teil bekannte Funktion provideA2uiCatalog, die nun den Katalog-Deskriptor entgegennimmt:
import { provideMarkdownRenderer } from '@a2ui/angular/v0_9';
import { provideCopilotKit } from '@copilotkit/angular';
import { marked } from 'marked';
import { a2uiActivityRendererConfig } from './domains/shared/util-copilotkit/a2ui/a2ui-activity-renderer';
import { provideA2uiCatalog } from './domains/shared/util-copilotkit/a2ui/provide-a2ui-catalog';
import { customCatalog } from './domains/ticketing/ai/custom-catalog/catalog';
[...]
export const appConfig: ApplicationConfig = {
providers: [
[...]
provideCopilotKit({
renderActivityMessages: [a2uiActivityRendererConfig],
}),
provideA2uiCatalog(customCatalog),
provideMarkdownRenderer(async (markdown) =>
marked.parse(String(markdown ?? '')),
),
],
};
Ohne Argumente registriert provideA2uiCatalog lediglich den Basic Catalog. Mit einem Deskriptor erweitert die Funktion diesen Katalog um die übergebenen Komponenten und Funktionen:
export const A2UI_CUSTOM_CATALOG = new InjectionToken<A2uiCustomCatalog>(
'A2UI_CUSTOM_CATALOG',
);
export function provideA2uiCatalog(
catalog?: A2uiCustomCatalog,
options?: ProvideA2uiCatalogOptions,
): EnvironmentProviders {
if (!catalog) {
return makeEnvironmentProviders([
{
provide: A2UI_RENDERER_CONFIG,
useFactory: (): RendererConfiguration => ({
catalogs: [inject(BasicCatalog)],
}),
},
A2uiRendererService,
]);
}
const { sendCatalogDescription = true } = options ?? {};
const rendererCatalog = new BasicCatalogBase({
id: catalog.id,
extraComponents: catalog.components.map(toAngularComponentImplementation),
functions: [
...BASIC_FUNCTIONS,
...(catalog.functions ?? []).map(toFunctionImplementation),
],
});
const storedCatalog: A2uiCustomCatalog = sendCatalogDescription
? catalog
: { id: catalog.id, components: [] };
return makeEnvironmentProviders([
{ provide: A2UI_CUSTOM_CATALOG, useValue: storedCatalog },
{
provide: A2UI_RENDERER_CONFIG,
useValue: { catalogs: [rendererCatalog] },
},
A2uiRendererService,
]);
}
Die lokalen Helfer toAngularComponentImplementation und toFunctionImplementation sind schlichte Ummappings der Deskriptor-Einträge auf die eingangs gezeigten Renderer-Strukturen; ihre Details finden sich im Source Code. Das Zusammenführen mit BASIC_FUNCTIONS übernimmt die Funktion gleich mit – die weiter oben erwähnte Asymmetrie bleibt damit ein Implementierungsdetail. Zusätzlich legt sie den Deskriptor im Injection Token A2UI_CUSTOM_CATALOG ab; welche Rolle dieser spielt, zeigt der nächste Abschnitt. Die Option sendCatalogDescription bestimmt, ob eine Beschreibung des gesamten Katalogs oder nur seine Id an den Server zu senden ist.
Der im zweiten Teil vorgestellte Activity Renderer bleibt von all dem unberührt: Er reicht die empfangenen A2UI-Operationen weiterhin an den A2uiRendererService weiter, und dieser löst die Custom Components über den registrierten Katalog auf.
Den Agent über Custom Components informieren
Der Renderer kann das TicketWidget jetzt darstellen – aber woher weiß das Sprachmodell, dass es diese Komponente überhaupt gibt? Diese Aufgabe übernimmt die im zweiten Teil vorgestellte Glue-Funktion initAgentStore: Beim Registrieren eines Agents liest sie den im Injection Token A2UI_CUSTOM_CATALOG abgelegten Deskriptor aus und meldet ihn als Kontext-Eintrag für diesen Agent an (gekürzt):
import { type Context } from '@ag-ui/core';
import { inject } from '@angular/core';
import { connectAgentContext } from '@copilotkit/angular';
import {
catalogIdToContextEntry,
catalogToContextEntry,
} from './a2ui/catalog-context';
import { A2UI_CUSTOM_CATALOG } from './a2ui/provide-a2ui-catalog';
[...]
export function initAgentStore(config: InitAgentStoreConfig): void {
[...]
connectCatalogContext(config.agentId, config.catalogIdOnly ?? false);
[...]
}
function connectCatalogContext(agentId: string, idOnly: boolean): void {
const catalog = inject(A2UI_CUSTOM_CATALOG, { optional: true });
if (!catalog) {
return;
}
const entry = idOnly
? catalogIdToContextEntry(catalog.id)
: catalogToContextEntry(catalog);
connectAgentContext(() => ({ ...entry, agentIds: [agentId] }) as Context);
}
Kontext-Einträge sind ein von AG-UI vorgesehener, generischer Mechanismus, um dem Agent zusätzliche Informationen mitzugeben. Die Funktion connectAgentContext aus @copilotkit/angular registriert dafür eine Factory bei der CopilotKit-Runtime, deren Ergebnis beim Aufbau der Requests in den Kontext einfließt. Über die Eigenschaft agentIds ist der Eintrag auf den gerade registrierten Agent beschränkt – in einer Anwendung mit mehreren Agents erhält so jeder genau seinen eigenen Katalog-Eintrag. Da der Katalog zur Laufzeit unverändert bleibt, wird er einmalig beim Initialisieren serialisiert.
Die einzelnen Agent-Stores kommen dadurch ohne Katalog-Wissen aus: Die im zweiten Teil gezeigte Funktion injectTicketingAgentStore bleibt unverändert; der Katalog fließt automatisch ein, sobald provideA2uiCatalog ihn im Injection Token abgelegt hat. Für Agents, die keine Komponenten-Beschreibungen benötigen, sondern lediglich die Katalog-Id referenzieren müssen, sieht initAgentStore zusätzlich die Option catalogIdOnly: true vor.
Die Serialisierung übernimmt die Hilfsfunktion catalogToContextEntry: Sie überführt die Katalog-Id, die Namen und Beschreibungen der Custom Components sowie – mithilfe von zodToJsonSchema – deren Schemata in einen Kontext-Eintrag. Auch ihre Implementierung findet sich im Abschnitt am Ende des Artikels.
Die Server-Perspektive: Custom Catalog aus Context nutzen
Auf der Server-Seite muss der Agent den empfangenen Kontext-Eintrag wieder auswerten. Diese Aufgabe übernimmt im Beispielprojekt die Funktion addCustomCatalogInstructions aus dem Ordner libs/ag-ui-server. Der genutzte Mastra-Agent bindet sie direkt bei seinen instructions ein:
export const ticketingAgent = new Agent({
id: 'ticketingAgent',
name: 'Flight42 Ticketing Assistant',
instructions: addCustomCatalogInstructions({
systemInstructions: ticketingAgentPrompt,
}),
[...]
});
Bemerkenswert ist dabei die Signatur von ticketingAgentPrompt: Der Systemprompt ist kein statischer String mehr, sondern eine Factory, die die Katalog-Id entgegennimmt und in die Anweisungen einwebt – etwa dort, wo der Prompt die Struktur der createSurface-Operationen vorgibt:
export function ticketingAgentPrompt(catalogId: string): string {
return `
[...]
- renderA2uiTool expects { messages: A2uiMessage[] } — one self-contained A2UI
v0.9 surface that MUST contain:
- one createSurface message with a fresh surfaceId and catalogId
"${catalogId}";
[...]
`;
}
Hinter dem Aufruf von addCustomCatalogInstructions steckt eine instructions-Factory, die die Katalog-Id aus dem Runtime-Kontext extrahiert, damit den Basis-Prompt aufbaut und die Komponenten-Beschreibung als zusätzlichen Abschnitt anhängt (gekürzt):
import {
A2UI_DEFAULT_CATALOG_ID,
catalogToPromptSection,
extractCatalogId,
} from './catalog-to-prompt.js';
[...]
export interface AddCustomCatalogInstructionsOptions {
/** Builds the system prompt for the catalog id the client registered. */
systemInstructions: (catalogId: string) => string;
[...]
}
export function addCustomCatalogInstructions(
options: AddCustomCatalogInstructionsOptions,
): (params: InstructionsParams) => string {
const { systemInstructions } = options;
return ({ requestContext }) => {
const agUi = requestContext.get('ag-ui') as AgUiRuntimeContext | undefined;
const catalogId =
extractCatalogId(agUi?.context) ?? A2UI_DEFAULT_CATALOG_ID;
const catalogSection = catalogToPromptSection(agUi?.context);
const baseInstructions = systemInstructions(catalogId);
return catalogSection
? `${baseInstructions}\n\n${catalogSection}`
: baseInstructions;
};
}
Die von AG-UI übertragenen Kontext-Einträge stehen unter dem Schlüssel ag-ui im Runtime-Kontext von Mastra bereit. Die Hilfsfunktion extractCatalogId liest daraus die Id des clientseitig registrierten Katalogs aus; meldet der Client keinen Custom Catalog an, greift mit A2UI_DEFAULT_CATALOG_ID die Id des Basic Catalog als Fallback. Die Hilfsfunktion catalogToPromptSection sucht in denselben Kontext-Einträgen den Eintrag mit der Beschreibung A2UI Custom Catalog, parst die serialisierte Katalog-Definition und formuliert daraus einen Prompt-Abschnitt. Dieser listet die verfügbaren Custom Components samt Beschreibung auf und leitet aus den JSON-Schemata einfache Beispiel-Props ab, an denen sich das Modell orientieren kann.
Damit schließt sich der Kreis: Der Client beschreibt seine Custom Components samt Katalog-Id, der Agent nimmt beides in den Prompt auf, das Sprachmodell referenziert Id und Komponenten in seinen A2UI-Nachrichten, und der Renderer stellt sie über den registrierten Katalog dar.
Sicherheitsaspekt: sendCatalogDescription und Prompt-Injection
Standardmäßig überträgt die gezeigte Lösung die textuellen Beschreibungen und Schemata der Komponenten an den Agent, der sie in den Systemprompt einbettet. Diese Vorgehensweise ist für die Entwicklung sehr komfortabel, kann jedoch in der Produktion zur Prompt-Injection missbraucht werden. Dabei schleust ein Angreifer schädliche Anweisungen in den Systemprompt ein und verleitet so das Sprachmodell zu ungewünschten Aktionen.
Deswegen bietet es sich im Produktionsbetrieb an, die Option sendCatalogDescription von provideA2uiCatalog auf false zu setzen:
provideA2uiCatalog(customCatalog, { sendCatalogDescription: false }),
In diesem Fall behält der Client die vollständige Katalog-Definition nur für das lokale Rendering; als Kontext-Eintrag überträgt er lediglich die Katalog-Id, die der Agent ohnehin für seine createSurface-Operationen benötigt. Das Schema des Katalogs ermittelt der Agent stattdessen anhand dieser Id über eine vertrauenswürdige Registry – zum Beispiel über eine systeminterne API oder Datenbank – und validiert die Id dabei gegen eine Liste freigegebener Kataloge.
Unter der Haube: Die Schema-Helfer im Detail
Zum Abschluss lohnt ein Blick auf die Implementierung jener Hilfsfunktionen, die sich clientseitig um das Schema des Custom Catalogs kümmern. Wer sie einfach aus dem Beispielprojekt übernimmt, kann diesen Abschnitt getrost überspringen; wer sie an das eigene Projekt anpassen möchte, findet hier die zentralen Bausteine.
Die Implementierung von createCustomComponent ist bewusst schlicht:
export interface CustomCatalogEntry<
TName extends string = string,
TSchema extends z.ZodObject<z.ZodRawShape> = z.ZodObject<z.ZodRawShape>,
> {
name: TName;
description: string;
schema: TSchema;
component: Type<{
props: Signal<ContextFromSchema<TSchema>>;
}>;
}
export function createCustomComponent<
const TName extends string,
const TSchema extends z.ZodObject<z.ZodRawShape>,
>(
entry: CustomCatalogEntry<TName, TSchema>,
): CustomCatalogEntry<TName, TSchema> {
return entry;
}
Die Funktion gibt den übergebenen Eintrag unverändert zurück – ihr Mehrwert liegt in der Typprüfung: Der Mapped Type ContextFromSchema leitet aus dem Zod-Schema jenen Kontext-Typ ab, den die Komponente über ihr props-Signal entgegennehmen muss; jede Eigenschaft wird dabei als BoundProperty erwartet. Passt das Schema nicht zur Komponente, schlägt bereits die Kompilierung fehl.
Auch createCustomCatalog ist eine reine Typisierungshilfe, die den übergebenen Deskriptor unverändert zurückgibt. Der Deskriptor umfasst neben der Id die Komponenten sowie optionale Funktionen:
export interface A2uiCustomCatalog {
id: string;
components: A2uiCustomCatalogComponent[];
functions?: A2uiCustomCatalogFunction[];
}
Die Funktion catalogToContextEntry schließlich serialisiert diesen Deskriptor für den Versand an den Agent:
import { type Context } from '@ag-ui/core';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { type A2uiCustomCatalog } from './types';
export const A2UI_CATALOG_CONTEXT_DESCRIPTION = 'A2UI Custom Catalog';
export function catalogToContextEntry(catalog: A2uiCustomCatalog): Context {
const components = Object.fromEntries(
catalog.components.map((component) => [
component.name,
{
description: component.description,
schema: zodToJsonSchema(component.schema, { $refStrategy: 'none' }),
},
]),
);
return {
description: A2UI_CATALOG_CONTEXT_DESCRIPTION,
value: JSON.stringify({ catalogId: catalog.id, components }),
};
}
Die Option $refStrategy: 'none' hält die erzeugten JSON-Schemata frei von $ref-Verweisen, sodass die Server-Seite sie ohne zusätzliche Auflösung weiterverarbeiten kann. Die Konstante A2UI_CATALOG_CONTEXT_DESCRIPTION dient dabei als Erkennungsmerkmal: Anhand genau dieser Beschreibung identifiziert die Server-Seite den Katalog-Eintrag unter den übermittelten Kontext-Einträgen.
Beachtenswert ist, dass die Funktion auch für einen Katalog ohne Komponenten einen Eintrag liefert: Die Katalog-Id erreicht den Server damit in jedem Fall – etwa wenn sendCatalogDescription: false die Beschreibungen zurückhält. Für Agents, die von vornherein nur die Id benötigen, bietet das Beispielprojekt zusätzlich die Kurzform catalogIdToContextEntry(catalogId) an, die initAgentStore bei gesetzter Option catalogIdOnly: true verwendet.
Zusammenfassung
Custom Catalogs erweitern A2UI gezielt um eigene Komponenten und Funktionen, die das Sprachmodell wie jeden anderen Baustein nutzen darf. Damit lassen sich generische Antworten in fachlich passende Oberflächen übersetzen, ohne den Charakter eines schlanken, deklarativen Protokolls zu verlieren. Schema-Validierung über Zod sorgt dabei für saubere Verträge zwischen Agent und Client, während die Aufteilung in Komponenten und Funktionen den Katalog flexibel hält.
In Kombination mit der CopilotKit-Anbindung lassen sich Custom Components mit createCustomComponent kompakt beschreiben und über einen einzigen Aufruf von provideA2uiCatalog einbinden. Die Katalog-Beschreibung wandert samt Katalog-Id als AG-UI-Kontext-Eintrag zum Agent, der die Id in seinen Systemprompt einwebt und fortan in seinen createSurface-Operationen verwendet – der Activity Renderer aus dem zweiten Teil bleibt dabei unverändert. Wer den Schritt in den Produktionsbetrieb plant, sollte den Sicherheitsaspekt rund um sendCatalogDescription mitdenken und Katalog-Schemata aus einer vertrauenswürdigen Quelle beziehen.
Wer Protokoll, Renderer, CopilotKit-Anbindung und Custom Catalogs kombiniert, hat eine solide Grundlage, um Sprachmodelle nicht nur Text, sondern echte UI-Antworten liefern zu lassen – und das auf eine Weise, die zur eigenen Anwendung passt.
Interesse an produktionsreifen Agentic-UI-Architekturen?
In meinem Workshop beschäftigen wir uns mit AG-UI, A2UI, MCP Apps, HITL-Patterns und modernen Angular-Architekturen für reale agentische Systeme.
FAQ
Was ist ein Custom Catalog in A2UI?
Ein Custom Catalog erweitert A2UI um eigene, fachlich getriebene Komponenten und Funktionen. Er bringt häufig den Basic Catalog als Übermenge mit und stellt dem Sprachmodell zusätzlich domänenspezifische Bausteine zur Verfügung, die der Renderer wie jede andere A2UI-Komponente verarbeitet.
Wie beschreibt man eine eigene A2UI-Komponente?
Die Implementierung ist eine reguläre Angular-Komponente, die ihre Inputs über ein Context-Objekt erhält. Zusätzlich zum Bauteil selbst wird ein Zod-Schema definiert, das die erwarteten Eigenschaften beschreibt. Komponente, Name und Schema werden gemeinsam als Eintrag in den Custom Catalog aufgenommen – in der CopilotKit-Anbindung typsicher über die Hilfsfunktion createCustomComponent.
Wie registriert man einen Custom Catalog in Angular?
Für den eigenständigen Renderer genügt eine Instanz von BasicCatalogBase, die im Token A2UI_RENDERER_CONFIG referenziert wird. In der CopilotKit-Anbindung übernimmt dies die Funktion provideA2uiCatalog, an die sich ein mit createCustomCatalog erstellter Deskriptor mit Komponenten und optionalen Funktionen direkt übergeben lässt.
Wie erfährt der Agent von den Custom Components?
Die Glue-Funktion initAgentStore, die die Agent-Stores registriert, liest den im Token A2UI_CUSTOM_CATALOG abgelegten Katalog aus, serialisiert ihn mit catalogToContextEntry und meldet ihn über connectAgentContext als AG-UI-Kontext-Eintrag für den jeweiligen Agent an. Auf der Server-Seite extrahiert addCustomCatalogInstructions die Katalog-Id, baut damit den Systemprompt auf und hängt die Komponenten-Beschreibungen samt daraus abgeleiteter Beispiel-Props an.
Wofür ist sendCatalogDescription da und wann sollte man die Option deaktivieren?
Standardmäßig (sendCatalogDescription: true) übermittelt der Client die Komponenten-Beschreibungen und Schemata an den Agent, der sie in den Systemprompt einbettet. Das ist sehr komfortabel, kann aber zu Prompt-Injection führen. Im Produktionsbetrieb empfiehlt es sich daher, die Option auf false zu setzen: Dann überträgt der Client nur noch die Katalog-Id, und der Server bezieht die Schemata aus einer vertrauenswürdigen Registry.

