Vom ersten Beispiel zur echten Agent-Anbindung über AG-UI.
Im echten Einsatz entstehen A2UI-Nachrichten nicht aus fest verdrahtetem Code, sondern als Antworten eines tatsächlichen Sprachmodells – und diese müssen zuerst vom Agenten zum Client befördert werden. AG-UI stellt dafür die richtige Transportschicht bereit, doch die offizielle Spezifikation definiert nicht, wie A2UI darüber laufen soll. Hier wird eine praktikable Methode vorgestellt und der Client über die Angular-Integration von CopilotKit angebunden.
Dieser zweite Teil einer dreiteiligen Serie führt dort weiter, wo der erste aufgehört hat: A2UI und der Angular-Renderer liefen dort noch mit fest einprogrammierten Nachrichten.
📂 Source Code (siehe Branch copilotkit)
Wie lassen sich A2UI-Nachrichten über AG-UI senden?
Bis jetzt haben wir A2UI isoliert betrachtet. Im Alltag kommuniziert der Client jedoch per HTTP mit dem Agenten. Genau dafür existiert das im vorherigen Artikel dieser Reihe behandelte Protokoll AG-UI, das die Verbindung zwischen Frontend und Agent standardisiert.
Eine elegante Einbindung von A2UI in dieses Ökosystem gelingt, indem die A2UI-Nachrichten innerhalb einer ACTIVITY_SNAPSHOT-Nachricht von AG-UI transportiert werden. Einen solchen Snapshot zeigen die Entwicklerwerkzeuge des Browsers:

Der Snapshot erscheint in textueller Darstellung wie folgt:
{
"type": "ACTIVITY_SNAPSHOT",
"messageId": "srf-france-confirm",
"activityType": "a2ui-surface",
"content": {
"operations": [
{
"version": "v0.9",
"createSurface": {
"surfaceId": "srf-france-confirm",
"catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
}
},
{
"version": "v0.9",
"updateComponents": {
[...]
}
},
{
"version": "v0.9",
"updateDataModel": {
"surfaceId": "srf-france-confirm",
"path": "/dummy",
"value": ""
}
}
]
}
}
Eine offizielle Spezifikation für die Übertragung von A2UI-Nachrichten über AG-UI existiert nicht. Der hier vorgestellte Ansatz – ein ACTIVITY_SNAPSHOT mit passendem activityType – fügt sich jedoch stimmig in die AG-UI-Semantik ein und deckt sich exakt mit der Auslegung von CopilotKit, dessen Angular-Integration wir im weiteren Verlauf auch clientseitig nutzen. Da das CopilotKit-Team zu den Urhebern von AG-UI zählt, wiegt ihre Interpretation des Standards naturgemäß schwer.
Statt eines ACTIVITY_SNAPSHOT ließe sich ebenso ein serverseitiger Toolcall einsetzen, zumal AG-UI den Client über Toolcalls und deren Resultate informiert. Eine solche Lösung setzte allerdings voraus, dass sich Client und Agent auf den Namen eines Tools einigen, das A2UI als Ergebnis zurückliefert.
Die Komponenten: A2UI-Renderer, CopilotKit und eine Server-Schicht
Zwei Bestandteile tragen die Umsetzung: der bereits im ersten Teil beschriebene Angular-Renderer für A2UI sowie die Angular-Anbindung von CopilotKit (@copilotkit/angular), die sämtliche AG-UI-Kommunikation übernimmt.
Der hier nur am Rande erwähnte serverseitige Agent bringt das Sprachmodell per Prompting dazu, A2UI-Strukturen zu generieren – aber nicht direkt für den Client, sondern als Eingabe für ein serverseitiges Tool. Dieses Tool übernimmt eine Schlüsselrolle: Es prüft die vom LLM erzeugten A2UI-Nachrichten und filtert alles heraus, was nicht konsistent oder ausführbar wäre.
Der Prompt fordert nicht bloß zur Erzeugung von A2UI auf. Zusätzlich enthält er mehrere vollständige Beispiel-Surfaces als Orientierungshilfe sowie einen Verweis auf das Schema des Basic Catalogs, das der Agent bei Bedarf abrufen kann. Das Modell erhält so nicht nur die grundlegende Struktur, sondern kann auch die Spezifika einzelner Komponenten gezielt nachschlagen.
Das ist unerlässlich, denn valide Ergebnisse von LLMs sind nie garantiert. Wenn die Validierung fehlschlägt, fließt das Feedback ins Modell zurück und ein neuer Versuch startet. Erst eine bestätigte Struktur wird anschließend weitergereicht.
Die server-seitige Implementierung verwandelt die geprüften A2UI-Nachrichten danach in einen ACTIVITY_SNAPSHOT. Er reiht sich in den normalen AG-UI-Stream ein und lässt sich dadurch von CopilotKit im Client einheitlich verarbeiten.
Client-Einbindung mit CopilotKit
Im Client-Bereich übernimmt CopilotKit die Verbindung zum Agent. Den Kern bildet ein Agent-Store: ein signalbasiertes Objekt, das den Chatverlauf hält, clientseitige Tool Calls ausführt und die ankommenden Nachrichten bereitstellt.
Dieser Store wird in der Funktion injectTicketingAgentStore initialisiert. Sie registriert den Agent mit seiner URL und den verfügbaren Client-Tools und gibt danach den Store zurück (abgekürzt dargestellt):
import { inject } from '@angular/core';
import { injectAgentStore } from '@copilotkit/angular';
import { initAgentStore } from '../../shared/util-copilotkit/init-agent-store';
[...]
export const TICKETING_AGENT_ID = 'ticketingAgent';
export function injectTicketingAgentStore() {
initAgentStore({
agentId: TICKETING_AGENT_ID,
url: inject(ConfigService).agUiUrl,
useServerMemory: true,
frontendTools: [
findFlightsTool,
getLoadedFlightsTool,
toggleFlightSelectionTool,
getCurrentBasketTool,
displayFlightDetailTool,
],
});
return injectAgentStore(TICKETING_AGENT_ID);
}
Die Helferfunktion initAgentStore bildet einen Teil einer minimalen Adapterschicht im Demonstrationsprojekt (im Verzeichnis util-copilotkit). Für die übergebene URL konstruiert sie einen Agenten, der von HttpAgent aus dem AG-UI-SDK abgeleitet ist, meldet ihn bei der CopilotKit-Runtime als Self-Managed Agent an und überträgt die bereitgestellten Client-Tools über CopilotKits registerFrontendTool-Funktion. Das useServerMemory-Flag bewirkt, dass ausschließlich neue Nachrichten zum Server gesendet werden, da dieser den Verlauf eigenständig speichert.
Das zentrale Bauteil stammt dagegen direkt aus CopilotKit: Mit injectAgentStore erhält man den signalbasierten AgentStore, durch den die Applikation auf Chatprotokoll (messages), Ausführungszustand (isRunning) sowie den Agenten zugreifen kann.
Ein Service aus dem Ticketing-Modul verwendet diese Funktion und ergänzt außerdem die Event-Listener für die A2UI-Oberflächen:
import { inject, Injectable } from '@angular/core';
import { CopilotKit, injectInterrupt } from '@copilotkit/angular';
[...]
@Injectable({ providedIn: 'root' })
export class TicketingChatService {
private readonly chatRegistry = inject(ChatRegistry);
private readonly copilotKit = inject(CopilotKit);
private readonly store = injectTicketingAgentStore();
[...]
constructor() {
registerHandlers({
checkIn: (action) => checkInAction(action),
submitAnswer: (action) =>
submitAnswerAction(action, this.copilotKit, this.store),
});
}
public init(): void {
this.chatRegistry.setChat({
store: this.store,
[...],
});
}
}
Um die Event-Handler zu definieren, nutzt die Hilfsfunktion registerHandlers intern die Eigenschaft onAction des A2UI-Renderers, die bereits im ersten Teil behandelt wurde. Für das Absenden einer Nachricht wird die Funktion sendMessage verwendet:
await sendMessage(
this.copilotKit,
this.store,
'Did I book my flight to France?',
);
Die Nachricht wird an den Agenten angehängt und über die CopilotKit-Runtime ein weiterer Durchlauf gestartet:
export async function sendMessage(
copilotKit: CopilotKit,
store: Signal<AgentStore>,
input: SendMessageInput,
): Promise<void> {
const agent = store().agent;
agent.addMessage({ id: randomUUID(), role: 'user', content: input });
await copilotKit.core.runAgent({ agent });
}
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.
Einen A2UI Activity Renderer bereitstellen
Für Nachrichten, die als activity gekennzeichnet sind, definiert CopilotKit sogenannte Activity Renderer: Das sind Angular-Komponenten, die je nach activityType bestimmen, wie der Inhalt eines Snapshots angezeigt wird.
CopilotKit bietet zwar schon eine integrierte A2UI-Unterstützung an, doch diese basierte zum Zeitpunkt der Artikelentstehung auf einem Renderer für Web Components, weshalb eigene Kataloge nur darüber realisierbar waren. Stattdessen wollen wir den Angular-Renderer aus dem ersten Teil einsetzen – das erfordert erfreulich wenig Aufwand, da dieser Renderer die Darstellung weiterhin übernimmt.
Wir starten mit dem content des ACTIVITY_SNAPSHOT. CopilotKit erwartet hierfür ein Schema und validiert damit die empfangenen Daten vor der Anzeige. Dieses Schema bauen wir hier mit Zod auf – bei A2UI besteht der Inhalt ausschließlich aus der Liste der Operationen:
import { z } from 'zod';
export const a2uiSurfaceContentSchema = z.object({
operations: z.array(z.custom<A2uiMessage>()),
});
export type A2uiSurfaceContent = z.infer<typeof a2uiSurfaceContentSchema>;
Das Schema ist absichtlich schlank gehalten: Es sichert lediglich ab, dass operations als Array vorliegt, unterzieht die einzelnen Operationen jedoch keiner weiteren Prüfung. Durch z.custom<A2uiMessage>() wird allein der TypeScript-Typ vorgegeben, eine Validierung zur Laufzeit bleibt aus. Damit genügt das Schema zunächst den Anforderungen von CopilotKit; die eigentliche inhaltliche Kontrolle der Operationen obliegt ohnehin dem A2UI-Renderer. Ein strengeres Schema lässt sich hier auf Wunsch selbstverständlich hinterlegen.
Ein angenehmer Nebeneffekt: z.infer leitet den passenden TypeScript-Typ A2uiSurfaceContent automatisch ab.
Auf dieser Grundlage lässt sich nun der eigentliche Activity Renderer konstruieren. Dieser reicht die eingehenden A2UI-Operationen an den A2uiRendererService weiter, präsentiert das daraus entstandene Surface mithilfe der im ersten Teil erläuterten SurfaceComponent und steuert dessen Lebenszyklus (verkürzt dargestellt):
import { A2uiRendererService, SurfaceComponent } from '@a2ui/angular/v0_9';
[...]
@Component({
selector: 'app-a2ui-activity-renderer',
imports: [SurfaceComponent],
host: { class: 'a2ui-surface' },
template: `
@let surface = surfaceId();
@if (surface) {
<a2ui-v09-surface [surfaceId]="surface" />
}
`,
})
export class A2uiActivityRenderer
implements ActivityRenderer<A2uiSurfaceContent>
{
readonly activityType = input.required<string>();
readonly content = input.required<A2uiSurfaceContent>();
readonly message = input.required<ActivityMessage>();
readonly agent = input.required<AbstractAgent | undefined>();
private readonly renderer = inject(A2uiRendererService);
private renderedSurfaceId: string | null = null;
constructor() {
effect(() => {
const operations = this.content().operations;
const surfaceId = getRenderedSurfaceId(operations);
if (!surfaceId || surfaceId === this.renderedSurfaceId) {
return;
}
this.releaseSurface();
this.renderedSurfaceId = surfaceId;
this.renderer.processMessages(operations);
});
inject(DestroyRef).onDestroy(() => {
this.releaseSurface();
});
}
private releaseSurface(): void {
if (this.renderedSurfaceId) {
this.renderer.surfaceGroup.deleteSurface(this.renderedSurfaceId);
this.renderedSurfaceId = null;
}
}
protected readonly surfaceId = computed(() =>
getRenderedSurfaceId(this.content().operations),
);
}
Damit CopilotKit die Komponente als Activity Renderer nutzen kann, setzt sie das ActivityRenderer-Interface aus @copilotkit/angular um. Über den Typparameter wird festgelegt, wie der Snapshot-Inhalt aufgebaut ist – in diesem Fall unser A2uiSurfaceContent. Das Interface schreibt vier Inputs vor:
content: der validierte Snapshot-Inhalt, bei A2UI die OperationslisteactivityType: die Art der Activity, hiera2ui-surfacemessage: die komplette AG-UI-Nachrichtagent: der zugehörige Agent, über den sich beispielsweise weitere Aktionen auslösen lassen
Der effect im Konstruktor initialisiert das Surface genau ein einziges Mal: Er holt sich über getRenderedSurfaceId die Surface-Id aus den Operationen, übergibt die Operationen beim ersten Auftreten einer neuen Id an processMessages und lässt alle weiteren Durchläufe aus, solange die Id gleich bleibt.
Die Lebensdauer der Komponente entspricht der des Surfaces: Beim Zerstören der Komponente – sowie beim Wechsel auf eine andere Surface-Id – entfernt releaseSurface das Surface über surfaceGroup.deleteSurface aus dem Renderer. Dadurch kann dieselbe Surface-Id später erneut aufgebaut werden, ohne dass der Chat-hostende Screen selbst aufräumen muss – die Surface-Lebensdauer liegt in der Verantwortung der Renderer-Komponente.
Die Übergabe an den Renderer passiert bewusst im Effect und nicht im computed: processMessages verändert den Renderer-Zustand und schreibt dabei eigene Signals. Solche Nebenwirkungen gehören nicht in ein computed.
Offen bleibt, woher die surfaceId für die SurfaceComponent stammt. Die Hilfsfunktion getRenderedSurfaceId liefert die Antwort: Sie durchsucht die Operationen des Snapshots nach der ersten auffindbaren Surface-Id:
function getRenderedSurfaceId(operations: A2uiMessage[]): string | null {
for (const operation of operations) {
if ('createSurface' in operation && operation.createSurface.surfaceId) {
return operation.createSurface.surfaceId;
}
if (
'updateComponents' in operation &&
operation.updateComponents.surfaceId
) {
return operation.updateComponents.surfaceId;
}
if ('updateDataModel' in operation && operation.updateDataModel.surfaceId) {
return operation.updateDataModel.surfaceId;
}
}
return null;
}
Die Notwendigkeit für diese Suche ergibt sich aus der Beschaffenheit der A2UI-Nachrichten: Die surfaceId ist nicht direkt am Snapshot verankert, sondern verteilt sich über die einzelnen Operationen – abhängig vom jeweiligen Operationstyp an verschiedenen Stellen. In der Regel enthält schon die erste Operation eines Snapshots ein createSurface mit der gesuchten Id. Beim inkrementellen Streamen kann ein Snapshot jedoch ebenfalls beginnen mit einem updateComponents oder updateDataModel für ein bereits vorhandenes Surface. Sollte keinerlei Id auffindbar sein, gibt die Funktion null zurück, und das Template bleibt vorerst ohne Darstellung.
Um CopilotKit mitzuteilen, welche Komponente für welchen activityType verantwortlich ist, greift man auf ein Konfigurationsobjekt zurück:
export const a2uiActivityRendererConfig: RenderActivityMessageConfig<A2uiSurfaceContent> =
{
activityType: 'a2ui-surface',
content: a2uiSurfaceContentSchema,
component: A2uiActivityRenderer,
};
Das bereits definierte Zod-Schema wird an dieser Stelle nicht nur zur Verknüpfung von activityType mit der passenden Komponente herangezogen, sondern auch zur Validierung des Snapshot-Inhalts durch CopilotKit vor der Übergabe an die Komponente. Dadurch bekommt der Activity Renderer ausschließlich Daten, die exakt der erwarteten Struktur entsprechen.
Aufbau des Chatverlaufs und Darstellung von A2UI-Oberflächen im Template
Alle Chat-Nachrichten liegen im messages-Signal des Agent-Stores. Sobald CopilotKit einen ACTIVITY_SNAPSHOT empfängt, wird dieser als Nachricht mit der Rolle activity und dem jeweiligen activityType in das Signal aufgenommen. In der Template-Datei der Chat-Komponente werden die Nachrichten durchlaufen und – in leicht vereinfachter Form – nach Text, Tool-Aufrufen sowie Activities sortiert:
@for (message of messages(); track message.id) {
@if (message.content) {
<div>{{ message.content }}</div>
}
@for (toolCall of message.toolCalls; track toolCall.id) {
<copilot-render-tool-calls
[message]="message"
[messages]="messages()"
[agentId]="agentId()" />
}
@if (message.role === 'activity') {
<app-copilot-activity
[message]="message"
[agentId]="agentId()" />
}
}
Die von CopilotKit bereitgestellte Komponente copilot-render-tool-calls ist für die Darstellung der Tool Calls verantwortlich. Im Fall der Activities greift das Beispiel auf die schlanke Komponente CopilotActivity zurück, die basierend auf dem activityType den passenden, bei CopilotKit registrierten Activity-Renderer auswählt und zur Anzeige bringt.
An diesem Punkt kommt die Verbindung zu A2UI ins Spiel: Für den activityType a2ui-surface wird von CopilotKit der zuvor gezeigte Activity Renderer aufgefunden. Die Registrierung erfolgt über provideCopilotKit in der appConfig. Ebenso befindet sich dort die aus dem ersten Teil bekannte A2UI-Konfiguration: der Basic Catalog am Token A2UI_RENDERER_CONFIG, der A2uiRendererService sowie der via provideMarkdownRenderer eingebundene Markdown-Renderer:
import {
A2UI_RENDERER_CONFIG,
A2uiRendererService,
BasicCatalog,
provideMarkdownRenderer,
} from '@a2ui/angular/v0_9';
import { provideCopilotKit } from '@copilotkit/angular';
[...]
export const appConfig: ApplicationConfig = {
providers: [
provideCopilotKit({
renderActivityMessages: [a2uiActivityRendererConfig],
}),
{
provide: A2UI_RENDERER_CONFIG,
useFactory: () => ({
catalogs: [inject(BasicCatalog)],
}),
},
provideMarkdownRenderer(async (markdown) =>
marked.parse(String(markdown ?? '')),
),
A2uiRendererService,
],
};
The only novel aspect here is the invocation of provideCopilotKit, which receives the previously discussed a2uiActivityRendererConfig. For readability, the sample project aggregates the providers for the catalog configuration and the A2uiRendererService into a dedicated provideA2uiCatalog function, which, in the third installment, additionally accepts a custom catalog.
This establishes the link between the agent and the A2UI renderer: the agent emits its A2UI messages as AG-UI ACTIVITY_SNAPSHOTs, CopilotKit intercepts them and exposes them as activity messages in the agent store, and the registered activity renderer passes the embedded operations along to the A2UI renderer. Individual components within our application need not understand the details of either the AG-UI or the A2UI protocol.
Summary
When paired with AG-UI, A2UI slots seamlessly into an existing agentic architecture. AG-UI manages structured communication between the client and the agent, while A2UI carries the UI-specific content. Through CopilotKit's Angular integration, this combination can be leveraged in Angular in a straightforward and idiomatic manner: the agent store exposes the chat history as a signal, and activity renderers bridge the incoming ACTIVITY_SNAPSHOTs with the A2UI renderer.
A significant advantage of this setup is server-side validation: it shields the client from inconsistent language model responses and guarantees that only verified structures eventually reach the browser. Moreover, the optional DSL variant demonstrates how the same mechanism can be tailored to accommodate models of varying capabilities.
Up Next
So far, we have relied on the Basic Catalog—the next part will reveal how to extend this with domain-specific components as a Custom Catalog.
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.
Häufig gestellte Fragen
Wie lassen sich A2UI-Nachrichten über AG-UI übertragen?
Es existiert keine verbindliche Norm. In der Praxis hat sich bewährt, die A2UI-Operationen innerhalb einer AG-UI-Nachricht vom Typ ACTIVITY_SNAPSHOT zu kapseln und mit einem individuellen activityType wie a2ui-surface zu versehen. Dieses Vorgehen fügt sich in die Semantik von AG-UI ein und deckt sich mit der Auslegung, die CopilotKit (Link) vornimmt.
Welchen Zweck erfüllt eine serverseitige Validierung?
Sprachmodelle erzeugen nicht durchgängig verlässliche A2UI-Strukturen. Eine Validierung auf dem Server – zum Beispiel innerhalb eines Tools – erkennt fehlerhafte Ausgaben, übermittelt dem Modell eine Rückmeldung und gewährleistet, dass der Client nur kohärente und ausführbare Strukturen erhält.
Welche Aufgaben übernimmt CopilotKit auf der Client-Seite?
Die Angular-Integration von CopilotKit (@copilotkit/angular) steuert die Kommunikation mit dem Agenten über AG-UI. Der durch injectAgentStore zugängliche Agent-Store hält den Chatverlauf in einem Signal verfügbar, führt Tools auf dem Client aus und bereitet eingehende ACTIVITY_SNAPSHOT-Nachrichten als Activity-Messages auf. Speziell registrierte Activity-Renderer, die etwa für A2UI konfiguriert sind, präsentieren deren Daten. Einzelne Komponenten benötigen dadurch kein detailliertes Wissen über AG-UI oder A2UI.
Wann empfiehlt sich eine eigene DSL statt direktem A2UI?
Eine eingeschränkte, auf die Anwendung zugeschnittene DSL kann die Strukturerzeugung für weniger leistungsfähige oder preisgünstigere Modelle erheblich stabilisieren. Die Umwandlung in A2UI übernimmt dann der Server zentral. Als Ausgleich entsteht eine zusätzliche Übersetzungsebene und die Flexibilität gegenüber nativem A2UI nimmt etwas ab.


