A2UI mit AG-UI und CopilotKit in Angular integrieren

  1. AG-UI verstehen: Der Standard für Agentic User Interfaces
  2. AG-UI in der Praxis: Das SDK für TypeScript
  3. AG-UI End-2-End: Server und Client verbinden
  4. Agentic UI mit Angular, CopilotKit und AG-UI umsetzen
  5. A2UI: Wie KI dynamische UIs zur Laufzeit erzeugt
  6. A2UI mit AG-UI und CopilotKit in Angular integrieren
  7. Custom Catalogs in A2UI: Eigene Komponenten für KI-generierte UIs
  8. A2UI mit DSL: Kontrollierbare Dashboards optimiert für Performance
  9. Agentic UI mit MCP Apps: Tool-Ergebnisse als interaktive Widgets
  10. MCP Apps in Angular mit CopilotKit: Rich Chat Interfaces Instead of Text Responses

Vom ersten Beispiel zur echten Agent-Anbindung über AG-UI.

In der Praxis stammen A2UI-Nachrichten nicht aus statischem Code, sondern aus den Antworten eines echten Sprachmodells – und die müssen erst einmal vom Agent zum Client transportiert werden. AG-UI bietet dafür die passende Transportschicht, doch die offizielle Spezifikation lässt offen, wie A2UI darüber zu übertragen ist. Dieser Artikel zeigt eine pragmatische Lösung und bindet den Client mit der Angular-Integration von CopilotKit an.

Dieser zweite Teil der dreiteiligen Serie schließt damit an den ersten Teil an, in dem wir A2UI und den Angular-Renderer noch mit hartcodierten Nachrichten betrieben haben.

📂 Source Code (siehe Branch copilotkit)

Wie überträgt man A2UI-Nachrichten über AG-UI?

Bisher haben wir A2UI für sich allein betrachtet. In der Praxis kommuniziert der Client jedoch über HTTP-Aufrufe mit dem Agent. Genau hier kommt das im vorigen Artikel dieser Reihe erwähnte Protokoll AG-UI ins Spiel, das die Kommunikation zwischen Frontend und Agent standardisiert.

Wir können A2UI elegant in diese Welt einbetten, indem wir die A2UI-Nachrichten in einer AG-UI-Nachricht vom Typ ACTIVITY_SNAPSHOT übermitteln. Einen solchen Snapshot zeigen die Developer Tools des Browsers:

Browser-Devtools-Trace: A2UI-Operationen createSurface, updateComponents und updateDataModel eingebettet in einen AG-UI-ACTIVITY_SNAPSHOT

In textueller Form sieht der Snapshot folgendermaßen aus:

{
  "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": ""
        }
      }
    ]
  }
}

Leider gibt es keine offizielle Definition dafür, wie A2UI-Nachrichten über AG-UI zu übertragen sind. Die hier gezeigte Lösung, ein ACTIVITY_SNAPSHOT mit einem entsprechenden activityType, passt jedoch gut zur Semantik von AG-UI und entspricht genau der Interpretation von CopilotKit, dessen Angular-Integration wir weiter unten auch auf der Client-Seite einsetzen. Da die Macher von CopilotKit zu den Initiatoren von AG-UI gehören, kommt ihrer Interpretation des Standards naturgemäß eine große Bedeutung zu.

Als Alternative zu einem ACTIVITY_SNAPSHOT wäre auch ein serverseitiger Toolcall denkbar, zumal AG-UI den Client über Toolcalls sowie deren Ergebnisse informiert. In diesem Fall müssten sich jedoch Client und Agent auf den Namen eines solchen Tools einigen, das A2UI als Ergebnis liefert.

Die Bausteine: A2UI-Renderer, CopilotKit und eine serverseitige Schicht

Für die Umsetzung greifen wir auf zwei Bausteine zurück: den im ersten Teil besprochenen Angular-Renderer für A2UI und die Angular-Integration von CopilotKit (@copilotkit/angular), die die AG-UI-Kommunikation übernimmt.

Der hier nicht näher betrachtete serverseitige Agent leitet das Sprachmodell per Prompting dazu an, A2UI-Strukturen zu erzeugen – allerdings nicht direkt für den Client, sondern als Input für ein serverseitiges Tool. Dieses Tool spielt eine zentrale Rolle: Es validiert die vom LLM generierten A2UI-Nachrichten und stellt sicher, dass nur konsistente und ausführbare Strukturen weiterverarbeitet werden.

Der Prompt beschränkt sich dabei nicht auf die Aufforderung, A2UI zu erzeugen. Er enthält daneben einige vollständige Beispiel-Surfaces, an denen sich das Modell orientieren kann, sowie einen Verweis auf das Schema des Basic Catalogs, das der Agent bei Bedarf abrufen kann. Damit kennt das Modell nicht nur die grobe Struktur einer A2UI-Antwort, sondern kann auch die Details der einzelnen Komponenten nachschlagen.

Das ist entscheidend, denn LLMs liefern nicht garantiert valide Ergebnisse. Schlägt die Validierung fehl, wird das Feedback zurück an das Modell gegeben, sodass es einen neuen Versuch unternehmen kann. Erst wenn eine valide Struktur vorliegt, wird sie weitergeleitet.

Die serverseitige Implementierung transformiert die validierten A2UI-Nachrichten anschließend in einen ACTIVITY_SNAPSHOT. Dieser wird Teil des regulären AG-UI-Streams und kann dadurch von CopilotKit auf der Client-Seite einheitlich verarbeitet werden.

Clientseitige Anbindung mit CopilotKit

Auf der Client-Seite kümmert sich CopilotKit um die Verbindung zum Agent. Das zentrale Konstrukt ist dabei ein Agent-Store: ein Signal-basiertes Objekt, das den Chatverlauf verwaltet, clientseitige Tool Calls ausführt und die empfangenen Nachrichten zur Verfügung stellt.

Die Initialisierung dieses Stores findet in der Funktion injectTicketingAgentStore statt. Sie registriert den Agent unter anderem mit seiner URL und den angebotenen Client-Tools und liefert anschließend den Store zurück (leicht vereinfacht):

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 Hilfsfunktion initAgentStore gehört zu einer schlanken Glue-Schicht des Beispielprojekts (Ordner util-copilotkit). Sie erzeugt für die angegebene URL einen vom HttpAgent des AG-UI-SDK abgeleiteten Agent, registriert ihn in der CopilotKit-Runtime als sogenannten Self-Managed Agent und meldet die übergebenen Client-Tools über CopilotKits registerFrontendTool an. Das Flag useServerMemory sorgt dafür, dass nur neue Nachrichten an den Server übertragen werden, weil dieser den Verlauf selbst speichert.

Das eigentliche Herzstück kommt hingegen direkt von CopilotKit: injectAgentStore liefert den Signal-basierten AgentStore, über den die Anwendung auf Chatverlauf (messages), Ausführungsstatus (isRunning) und den Agent selbst zugreift.

Ein Service des Ticketing-Domänenbereichs nutzt diese Funktion und registriert zusätzlich die Event-Handler für die A2UI-Surfaces:

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,
      [...],
    });
  }
}

Die Hilfsfunktion registerHandlers definiert die Event-Handler und delegiert dazu intern an die im ersten Teil besprochene Eigenschaft onAction des A2UI-Renderers. Zum Senden einer Nachricht kommt die Funktion sendMessage zum Einsatz:

await sendMessage(
  this.copilotKit,
  this.store,
  'Did I book my flight to France?',
);

Sie hängt die Nachricht an den Agent an und stößt über die CopilotKit-Runtime einen neuen Lauf an:

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 });
}

NOTE

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.

Cover des eBooks Agentic UI with Angular

Mehr zum eBook →

Einen A2UI Activity Renderer bereitstellen

Für Nachrichten mit der Rolle activity sieht CopilotKit sogenannte Activity Renderer vor: Angular-Komponenten, die pro activityType festlegen, wie der Inhalt eines Snapshots darzustellen ist.

CopilotKit bringt zwar bereits eine eigene A2UI-Unterstützung mit. Diese setzte zum Zeitpunkt der Erstellung dieses Artikels jedoch auf einen Renderer für Web Components auf, sodass sich eigene Kataloge nur mit Web Components umsetzen ließen. Wir wollen stattdessen den Angular-Renderer aus dem ersten Teil einklinken – und das ist erfreulich wenig Arbeit, denn die eigentliche Darstellung übernimmt weiterhin dieser Renderer.

Beginnen wir mit dem content des ACTIVITY_SNAPSHOT. CopilotKit verlangt dafür ein Schema und prüft damit den empfangenen Inhalt, bevor er zur Anzeige kommt. Wir setzen dieses Schema hier mit Zod um – im Fall von A2UI besteht der Inhalt lediglich 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 fällt bewusst kompakt aus: Es stellt sicher, dass operations überhaupt ein Array ist, prüft die einzelnen Operationen aber nicht weiter. Das z.custom<A2uiMessage>() gibt lediglich den TypeScript-Typ vor, ohne zur Laufzeit zu validieren. Damit ist die Anforderung von CopilotKit zunächst einmal erfüllt; die inhaltliche Prüfung der Operationen übernehmen ohnehin der A2UI-Renderer. Wer möchte, kann hier natürlich ein strengeres Schema hinterlegen.

Praktischerweise liefert z.infer den passenden TypeScript-Typ A2uiSurfaceContent gleich mit.

Damit lässt sich nun der eigentliche Activity Renderer umsetzen. Er übergibt die empfangenen A2UI-Operationen an den A2uiRendererService, zeigt das daraus entstehende Surface über die im ersten Teil besprochene SurfaceComponent an und verwaltet dessen Lebenszyklus (gekürzt):

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 diese Komponente als Activity Renderer ansprechen kann, implementiert sie das aus @copilotkit/angular stammende Interface ActivityRenderer. Der Typparameter legt fest, welche Struktur der Snapshot-Inhalt hat – hier also unser A2uiSurfaceContent. Das Interface gibt vier Inputs vor:

  • content: der geprüfte Inhalt des Snapshots, im Fall von A2UI die Liste der Operationen
  • activityType: der Typ der Activity, hier a2ui-surface
  • message: die gesamte AG-UI-Nachricht
  • agent: der zugehörige Agent, über den sich etwa Folgeaktionen anstoßen lassen

Der effect im Konstruktor baut das Surface genau einmal auf: Er ermittelt über getRenderedSurfaceId die Surface-Id aus den Operationen, reicht die Operationen beim ersten Auftreten einer neuen Id an processMessages weiter und überspringt weitere Durchläufe, solange die Id unverändert bleibt.

Die Komponente besitzt die Lebensdauer des Surfaces: Beim Zerstören der Komponente – und beim Wechsel auf eine andere Surface-Id – entfernt releaseSurface das Surface über surfaceGroup.deleteSurface wieder aus dem Renderer. Dieselbe Surface-Id lässt sich dadurch später erneut aufbauen, ohne dass der Screen, der den Chat hostet, aufräumen müsste – die Surface-Lebensdauer gehört der Renderer-Komponente.

Dass die Weitergabe an den Renderer im Effect und nicht im computed erfolgt, hat einen guten Grund: processMessages aktualisiert den Zustand des Renderers und schreibt dabei selbst Signals. Solche Seiteneffekte haben in einem computed nichts verloren.

Bleibt die Frage, woher die surfaceId für die SurfaceComponent kommt. Die Antwort liefert die Hilfsfunktion getRenderedSurfaceId: Sie durchsucht die Operationen des Snapshots nach der ersten Surface-Id, die sich finden lässt:

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;
}

Der Grund für diese Suche liegt in der Struktur der A2UI-Nachrichten: Die surfaceId steckt nicht am Snapshot selbst, sondern in den einzelnen Operationen – je nach Operationstyp an unterschiedlicher Stelle. Üblicherweise liefert bereits die erste Operation eines Snapshots ein createSurface mit der gesuchten Id. Beim schrittweisen Streamen kann ein Snapshot aber auch mit einem updateComponents oder updateDataModel für ein bereits bestehendes Surface beginnen. Findet sich gar keine Id, liefert die Funktion null und das Template zeigt vorerst nichts an.

Damit CopilotKit weiß, welche Komponente für welchen activityType zuständig ist, kommt ein Konfigurationsobjekt zum Einsatz:

export const a2uiActivityRendererConfig: RenderActivityMessageConfig<A2uiSurfaceContent> =
  {
    activityType: 'a2ui-surface',
    content: a2uiSurfaceContentSchema,
    component: A2uiActivityRenderer,
  };

Neben der Zuordnung von activityType und Komponente kommt hier das weiter oben definierte Zod-Schema zum Einsatz. CopilotKit prüft damit den Inhalt des Snapshots, bevor er an die Komponente gelangt. Der Activity Renderer erhält somit ausschließlich Inhalte in der erwarteten Struktur.

Chatverlauf und A2UI-Surfaces im Template anzeigen

Der gesamte Chatverlauf befindet sich im messages-Signal des Agent-Stores. Empfängt CopilotKit einen ACTIVITY_SNAPSHOT, taucht dieser dort als Nachricht mit der Rolle activity und dem jeweiligen activityType auf. Das Template der Chat-Komponente iteriert über die Nachrichten und unterscheidet nach Textinhalten, Tool Calls und Activities (leicht vereinfacht):

@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()" />
  }

}

Tool Calls übernimmt dabei die von CopilotKit mitgelieferte Komponente copilot-render-tool-calls. Für Activities nutzt das Beispiel die kleine Komponente CopilotActivity, die anhand des activityType den passenden, bei CopilotKit registrierten Activity-Renderer ermittelt und anzeigt.

Genau hier schließt sich der Kreis zu A2UI: Für den activityType a2ui-surface findet CopilotKit den weiter oben gezeigten Activity Renderer. Bekannt gemacht wird er über provideCopilotKit in der appConfig. Dort findet sich auch die aus dem ersten Teil bekannte A2UI-Konfiguration wieder: der Basic Catalog am Token A2UI_RENDERER_CONFIG, der A2uiRendererService sowie der über provideMarkdownRenderer angebundene 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,
  ],
};

Neu ist hier lediglich der Aufruf von provideCopilotKit, dem das oben besprochene a2uiActivityRendererConfig übergeben wird. Das Beispielprojekt fasst die Provider für die Katalog-Konfiguration und den A2uiRendererService zur besseren Lesbarkeit in einer eigenen Funktion provideA2uiCatalog zusammen, die im dritten Teil zusätzlich einen eigenen Katalog entgegennimmt.

Damit ist die Brücke zwischen Agent und A2UI-Renderer geschlagen: Der Agent liefert seine A2UI-Nachrichten über AG-UI-ACTIVITY_SNAPSHOTs, CopilotKit nimmt sie entgegen und stellt sie als Activity-Messages im Agent-Store bereit, und der registrierte Activity-Renderer delegiert die enthaltenen Operationen an den A2UI-Renderer. Die einzelnen Komponenten unserer Anwendung müssen weder das AG-UI- noch das A2UI-Protokoll im Detail kennen.

Zusammenfassung

In Kombination mit AG-UI fügt sich A2UI nahtlos in eine bestehende Agentic-Architektur ein. AG-UI übernimmt die strukturierte Kommunikation zwischen Client und Agent, während A2UI die UI-spezifischen Inhalte transportiert. Mit der Angular-Integration von CopilotKit lässt sich diese Kombination einfach und idiomatisch in Angular nutzen: Der Agent-Store liefert den Chatverlauf als Signal, und Activity-Renderer verbinden die empfangenen ACTIVITY_SNAPSHOTs mit dem A2UI-Renderer.

Ein nicht zu unterschätzender Vorteil dieser Architektur liegt in der Validierung auf Server-Seite: Sie schützt den Client vor inkonsistenten Antworten des Sprachmodells und sorgt dafür, dass nur überprüfte Strukturen am Ende im Browser landen. Die optionale DSL-Variante zeigt darüber hinaus, dass sich derselbe Mechanismus für unterschiedlich starke Modelle ausbalancieren lässt.

Der nächste Schritt

Bislang haben wir uns auf den Basic Catalog gestützt – im nächsten Teil zeigt sich, wie sich eigene, fachliche Komponenten als Custom Catalog ergänzen lassen.

Nächster Artikel →


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.

Workshop: Agentic AI mit Angular – AG-UI, A2UI, MCP Apps & HITL-Patterns

Alle Details →

FAQ

Wie überträgt man A2UI-Nachrichten über AG-UI?

Eine offizielle Festlegung gibt es nicht. In der Praxis bewährt sich, die A2UI-Operationen in einer AG-UI-Nachricht vom Typ ACTIVITY_SNAPSHOT mit einem eigenen activityType (zum Beispiel a2ui-surface) zu transportieren. Diese Variante passt zur Semantik von AG-UI und entspricht der Interpretation von CopilotKit.

Warum gehört eine serverseitige Validierung dazu?

Sprachmodelle liefern nicht garantiert valide A2UI-Strukturen. Eine serverseitige Validierung – etwa in einem Tool – fängt fehlerhafte Antworten ab, gibt Feedback an das Modell zurück und stellt sicher, dass nur konsistente und ausführbare Strukturen den Client erreichen.

Welche Rolle spielt CopilotKit auf der Client-Seite?

Die Angular-Integration von CopilotKit (@copilotkit/angular) verwaltet die Verbindung zum Agent über AG-UI. Der per injectAgentStore bereitgestellte Agent-Store liefert den Chatverlauf als Signal, führt clientseitige Tools aus und stellt empfangene ACTIVITY_SNAPSHOTs als Activity-Messages bereit. Registrierte Activity-Renderer – etwa für A2UI – zeigen deren Inhalte an. Damit müssen einzelne Komponenten weder AG-UI noch A2UI im Detail kennen.

Wann ist eine eigene DSL statt direktem A2UI sinnvoll?

Eine reduzierte, anwendungsspezifische DSL kann die Generierung für schwächere oder kostengünstigere Modelle deutlich robuster machen. Der Server übersetzt sie zentral nach A2UI. Der Preis dafür ist eine zusätzliche Transformationsschicht und etwas weniger Ausdrucksstärke gegenüber direktem A2UI.

Agentic UI with Angular

Architecting Agentic AI with Open Standards

Integriere AI-Agents in Angular mit offenen Standards.

Mehr zum Buch