Die Beispielanwendung im Überblick

Als durchgängiges Beispiel dient eine Flugbuchungsanwendung, die auch in den vorangegangenen Teilen der Serie verwendet wurde. Die Anwendung lässt sich auf herkömmliche Weise bedienen; wer jedoch nicht weiterkommt oder Arbeitsschritte verkürzen möchte, kann ein Sidecar aktivieren – einen Chat, über den ein serverseitiger Agent Unterstützung bietet. Der Agent antwortet mit Text, ruft sowohl client- als auch serverseitige Tools auf und blendet interaktive Elemente wie eine Flugkarte direkt in den Verlauf ein.

Sidecar in einer Flight-Booking-Anwendung mit Chat, Tool Calls und Flugkarte

CopilotKit in die Angular-Anwendung einbinden

CopilotKit baut unmittelbar auf dem bereits erläuterten AG-UI SDK auf: Der Agent wird auf der Client-Seite durch einen HttpAgent aus @ag-ui/client repräsentiert. CopilotKit verwaltet diesen Agent und übersetzt dessen gestreamte AG-UI-Events in einen Signal-basierten Chatverlauf. Die serverseitige Implementierung bleibt davon unberührt – wer bereits einen AG-UI-Endpunkt betreibt, kann diesen ohne Anpassungen weiterverwenden.

Die Integration in die Anwendung erfolgt über einen Provider in der app.config.ts:

// src/app/app.config.ts
import { provideCopilotKit } from '@copilotkit/angular';

export const appConfig: ApplicationConfig = {
  providers: [
    [...]
    provideCopilotKit({
      defaultToolRendering: true,
    }),
  ],
};

Mit der Option defaultToolRendering wird CopilotKits eingebauter Standard-Renderer für Tool-Aufrufe aktiviert, die über keine eigene Darstellungskomponente verfügen. Auf diese Weise werden solche Tool-Aufrufe im Chatverlauf festgehalten und bleiben für die Benutzer nachvollziehbar.

Dank AG-UI ist das Angular-Beispiel unabhängig von serverseitigen Technologien und Modellen. Um die Ausführung möglichst einfach zu gestalten, enthält die Demo einen Agent, der auf dem TypeScript-basierten Agent-FrameworkMastra aufsetzt. Da das AG-UI SDK für Mastra einen Adapter anbietet, lassen sich damit entwickelte Agents unkompliziert über AG-UI anbinden.

Der Client wurde sowohl mit OpenAIs GPT 5 als auch mit Googles Gemini 3 erprobt. Detaillierte Hinweise zum Einrichten und Starten der Demo finden sich in der Readme.

Agents über einen Agent Store anbinden

Der zentrale Baustein der CopilotKit-Integration in Angular ist der sogenannte Agent Store. Er verkörpert einen konkreten Agent inklusive Chatverlauf und Ausführungszustand. Das Demo-Projekt kapselt die Einrichtung des Ticketing-Agents in einer Funktion namens injectTicketingAgentStore:

// src/app/domains/ticketing/ai/ticketing-agent-store.ts
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: 'http://localhost:3001/ag-ui/ticketingAgent',
    useServerMemory: true,
    frontendTools: [
      findFlightsTool,
      getLoadedFlightsTool,
      toggleFlightSelectionTool,
      getCurrentBasketTool,
      displayFlightDetailTool,
      flightWidget,
    ],
  });

  return injectAgentStore(TICKETING_AGENT_ID);
}

Die Hilfsfunktion initAgentStore übernimmt die Registrierung des Agents samt seiner clientseitigen Tools bei CopilotKit; ihr Aufbau wird im folgenden Abschnitt genauer betrachtet. Anschließend liefert das von CopilotKit bereitgestellte injectAgentStore den eigentlichen Agent Store zurück – ein Signal<AgentStore>, das unter anderem den Chatverlauf (messages) und den Ausführungszustand (isRunning) über Signals bereitstellt.

Die Konfiguration verweist auf die URL des Agents. Dahinter verbirgt sich ein serverseitig umgesetzterTicketing-Agent, der Reisende bei ihren Flugbuchungen unterstützt. Dazu stehen ihm Tools zum Abrufen gebuchter Flüge sowie zum Buchen und Stornieren zur Verfügung (findBookedFlightsTool, bookFlightTool, cancelFlightTool). Die Umsetzung erfolgt in unserem Fall mit dem TypeScript-Framework Mastra; für die hier dargestellte Anbindung ist das jedoch irrelevant – allein die AG-UI-Unterstützung des Agents ist entscheidend.

Die Option useServerMemory legt fest, ob der Agent den Chatverlauf serverseitig speichert. Ist dies nicht der Fall, muss der Client den gesamten Verlauf bei jedem Aufruf erneut übertragen.

In der Auflistung frontendTools registriert der Konsument sämtliche clientseitigen Tools, deren Aufruf der Agent anfordern darf. Bemerkenswert ist, dass auch das Widget flightWidget – die eingangs erwähnte Flugkarte – in dieser Liste auftaucht. Das ist keineswegs ein Zufall: In CopilotKit sind Widgets nichts anderes als Frontend-Tools, denen eine Angular-Komponente für die Anzeige zugeordnet wurde.

NOTE

Agentic UI with Angular

Wer AG-UI nicht nur einbinden, sondern sauber in größere Architekturen integrieren möchte:
In meinem Buch Agentic UI mit Angular gehe ich detailliert auf diese Patterns und Trade-offs ein.

Cover des eBooks Agentic UI with Angular

Mehr zum eBook →

Die Hilfsfunktion initAgentStore im Detail

CopilotKit sieht momentan vor, dass Agents beim Aufruf von provideCopilotKit während des Anwendungsstarts konfiguriert und damit registriert werden. Für lazy geladene Feature-Bereiche erweist sich das als unpraktisch: Deren Tools und Widgets sollen erst mit dem jeweiligen Bundle in den Browser gelangen und nicht bereits im Hauptbundle enthalten sein.

Genau diese Lücke schließt initAgentStore. Sie läuft in einem Injection Context und erfüllt im Wesentlichen zwei Aufgaben: Sie registriert den Agent zur Laufzeit als sogenannten self-managed Agent bei CopilotKit und meldet anschließend alle Frontend-Tools für dessen agentId an (leicht gekürzt dargestellt):

// src/app/domains/shared/util-copilotkit/init-agent-store.ts
import { randomUUID } from '@ag-ui/client';
import { inject } from '@angular/core';
import { CopilotKit, registerFrontendTool } from '@copilotkit/angular';

[...]

export function initAgentStore(config: InitAgentStoreConfig): void {
  const copilotKit = inject(CopilotKit);

  const httpAgent = new AppHttpAgent(
    {
      agentId: config.agentId,
      url: config.url,
      threadId: randomUUID(),
    },
    { useServerMemory: config.useServerMemory },
  );

  copilotKit.updateRuntime({
    selfManagedAgents: {
      ...copilotKit.agents(),
      [config.agentId]: httpAgent,
    },
  });

  for (const tool of config.frontendTools ?? []) {
    registerFrontendTool({
      ...tool,
      agentId: config.agentId,
    });
  }

  [...]
}

Der Aufruf von updateRuntime erweitert die bei CopilotKit hinterlegten Agents um einen neuen Eintrag. BeiAppHttpAgent handelt es sich um eine schlanke Ableitung des HttpAgent aus dem AG-UI SDK. Sie setzt unter anderem die Option useServerMemory um: Speichert der Server den Chatverlauf, filtert der AppHttpAgent bereits übertragene Nachrichten aus den Requests heraus.

Die darauf folgende Schleife registriert die übergebenen Tools über registerFrontendTool bei CopilotKit. Dabei wird jedes Tool an die agentId gebunden – die Tool-Definitionen selbst bleiben auf diese Weise agnostisch und lassen sich wiederverwenden.

Ein erfreulicher Nebeneffekt: registerFrontendTool speichert den aktuellen Injection Context und führt die Tool-Handler später in diesem Kontext aus. Deshalb dürfen die Handler, wie im nächsten Abschnitt ersichtlich, Angulars inject verwenden.

Clientseitige Tools definieren

Die einzelnen Tools beschreibt das Demo-Projekt mit der Hilfsfunktion createFrontendTool:

// src/app/domains/ticketing/ai/tools/find-flights.tool.ts
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { z } from 'zod';

import { createFrontendTool } from '../../../shared/util-copilotkit/tool-definition';

[...]

export const findFlightsTool = createFrontendTool({
  name: 'findFlights',
  description: `Searches for flights and redirects the user to the result`,
  parameters: z.object({
    from: z.string().describe('airport of departure'),
    to: z.string().describe('airport of destination'),
  }),
  handler: async ({ from, to }) => {
    const store = inject(FlightStore);
    const router = inject(Router);
    store.updateFilter(from, to);
    await router.navigate(['/ticketing/booking/flight-search']);
    return { ok: true };
  },
});

Neben einem Namen, einer Beschreibung und Parameterdefinitionen, die auf Zod basieren, beinhaltet die Tool-Definition auch einen handler. Fordert der Agent das Tool an, führt CopilotKit diesen Handler aus – dank des zuvor erwähnten Injection Contexts inklusive funktionierendem inject.

Das Parameter-Objekt unterliegt dem TypeScript-Typ, der aus dem Zod-Schema abgeleitet wird. In unserem Fall handelt es sich um ein Objekt mit den Eigenschaften from und to. Die gezeigte Implementierung stößt eine Flugsuche an, indem sie diese Suchkriterien an den FlightStore übergibt und den Benutzer anschließend auf die Ergebnisseite navigiert.

Tools, die Informationen ermitteln – etwa lokal vorliegende Zustände oder Benutzerantworten –, können diese über den Rückgabewert an das Modell zurückmelden. Ein Beispiel hierfür ist das getLoadedFlightsTool, das den Agent über jene Flüge informiert, die die Anwendung dem Benutzer gerade präsentiert:

// src/app/domains/ticketing/ai/tools/get-loaded-flights.tool.ts
export const getLoadedFlightsTool = createFrontendTool({
  name: 'getLoadedFlights',
  description: `Returns the currently loaded/displayed flights`,
  parameters: z.object({}),
  handler: async () => {
    const store = inject(FlightStore);
    return store.flightsValue().map(toFlightInfo);
  },
});

Das Innenleben von createFrontendTool

Ein Blick hinter die Kulissen offenbart: createFrontendTool ist im Kern eine Identitätsfunktion, die die Tool-Definition lediglich durchreicht – sie registriert nichts und injiziert nichts. Einzig bei followUp: false ergänzt sie die Beschreibung um den weiter unten angeführten Hinweis (leicht gekürzt):

// src/app/domains/shared/util-copilotkit/tool-definition.ts
import { type FrontendToolConfig } from '@copilotkit/angular';

export function createFrontendTool<Args extends Record<string, unknown>>(
  tool: FrontendToolConfig<Args>,
): FrontendToolConfig<Args> {
  [...]
  return tool;
}

Der Zweck dieses Durchreichens liegt in der Type Inference: TypeScript leitet den generischen Typparameter Args aus dem übergebenen Zod-Schema in parameters ab und wendet ihn auf die gesamte Definition an. Dadurch kennt der handler die Typen seiner Parameter – im Fall von findFlights also from und to als string –, ohne dass die Tool-Definition sie gesondert annotieren müsste.

Ohne die Hilfsfunktion gäbe es nur weniger bequeme Alternativen: die Definition von Hand als FrontendToolConfig<...> zu typisieren und damit die Parametertypen redundant zum Schema zu pflegen, oder auf die Typisierung gänzlich zu verzichten und Tippfehler im Handler erst zur Laufzeit zu entdecken.

Widgets sind Tools mit zugeordneter Komponente

Für Widgets wie die Flugkarte sieht CopilotKit kein separates Konzept vor – und benötigt auch keines. Ein Widget ist schlicht ein Frontend-Tool, dem über die Eigenschaft component eine Angular-Komponente zugewiesen wurde. Ruft der Agent das Tool auf, rendert CopilotKit diese Komponente im Chatverlauf und übergibt ihr die vom LLM gelieferten Parameter:

// src/app/domains/ticketing/ui/flight-widget.ts
const flightSchema = z.object({
  id: z.number().describe('The flight id'),
  from: z.string().describe('Departure city'),
  to: z.string().describe('Arrival city'),
  date: z.string().describe('Departure date in ISO format'),
  delay: z.number().describe('Delay in minutes'),
});

const flightWidgetSchema = z.object({
  flight: flightSchema,
  status: z.enum(['booked', 'other', 'none']).describe('Status of the flight'),
});

export const flightWidget = createFrontendTool({
  name: 'flightWidget',
  description: `Displays a concrete flight as an interactive card.
Use it when referring to one or more specific flights.`,
  parameters: flightWidgetSchema,
  component: FlightWidget,
  followUp: false,
  handler: async () => ({ shown: true }),
});

Das Schema beschreibt hier also nicht nur die Parameter eines Funktionsaufrufs, sondern zugleich die Daten, mit denen die Komponente beliefert wird. Die Option followUp: false signalisiert CopilotKit, dass nach dem Anzeigen des Widgets kein weiterer Roundtrip zum Agent erforderlich ist – das Widget stellt den Abschluss der Antwort dar.

Entscheidend ist: followUp: false steuert lediglich die Client-Seite – damit auch das Modell solche Aufrufe als Abschluss seines Zugs interpretiert, hängt createFrontendTool im Demo-Projekt automatisch einen entsprechenden Hinweis an die Tool-Beschreibung und damit an den Prompt an.

Die zugeordnete Komponente implementiert das Interface ToolRenderer und erhält den Tool-Aufruf als Input. Über toolCall().args gelangt sie an die vom LLM übergebenen, vom Schema typisierten Werte:

// src/app/domains/ticketing/ui/flight-widget.ts
import { type AngularToolCall, type ToolRenderer } from '@copilotkit/angular';

@Component({
  selector: 'app-flight-widget',
  imports: [FlightCard, RouterLink],
  template: `
    @let flight = toolCall().args.flight;
    @if (flight) {
      <app-flight-card [item]="flight" [readonly]="true">
        [...]
      </app-flight-card>
    }
  `,
})
export class FlightWidget implements ToolRenderer<FlightWidgetArgs> {
  readonly toolCall = input.required<AngularToolCall<FlightWidgetArgs>>();
  [...]
}

Submitting Requests to the Agent

The chat component in the demo project—referred to as AssistantChat—derives its state from the agent store. The simplified version below obtains the store directly via the function introduced at the outset; the actual component receives it through a registry from the corresponding chat service, which invokes injectTicketingAgentStore:

// src/app/domains/shared/ui-assistant/assistant-chat/assistant-chat.ts
import { CopilotKit } from '@copilotkit/angular';

[...]

export class AssistantChat {
  private readonly copilotKit = inject(CopilotKit);

  protected readonly store = injectTicketingAgentStore();

  protected readonly messages = computed(() => this.store().messages());
  protected readonly isRunning = computed(() => this.store().isRunning());

  protected submit(): void {
    void sendMessage(this.copilotKit, this.store, this.message());
  }
}

The sendMessage function handles message submission. It appends the user's message to the agent and then triggers a run through CopilotKit:

// src/app/domains/shared/util-copilotkit/agent-store-helper.ts
export async function sendMessage(
  copilotKit: CopilotKit,
  store: Signal<AgentStore>,
  content: string,
): Promise<void> {
  const agent = store().agent;
  agent.addMessage({ id: randomUUID(), role: 'user', content });
  await copilotKit.core.runAgent({ agent });
}

Routing through copilotKit.core.runAgent is essential: only this path ensures that the previously registered frontend tools participate in the run. The client then simply renders the response messages returned by the agent via AG-UI in the conversation history.

Rendering the Conversation in Angular Templates

The entire chat history resides in the messages signal of the agent store. The template iterates over the messages and displays both the textual response (content) and any requested tool invocations. The latter is handled by CopilotKit's RenderToolCalls component:

<!-- src/app/domains/shared/ui-assistant/chat-messages/chat-messages.html -->
@for (message of messages(); track message.id) {
  @if (message.content) {
    <div>{{ message.content }}</div>
  }
  @if (message.role === 'assistant' && message.toolCalls?.length) {
    <copilot-render-tool-calls
      [message]="message"
      [messages]="messages()"
      [agentId]="'ticketingAgent'" />
  }
}

RenderToolCalls looks up the registered rendering component for each tool call—so for flightWidget, the interactive flight card appears. As for executing client-side tools, no manual effort is needed, since CopilotKit manages that entirely on its own.

The streamed AG-UI messages can be inspected through the browser's developer tools:

Gestreamte AG-UI-Nachrichten in den Developer Tools des Browsers

This information makes the protocol tangible and also proves valuable for debugging purposes.

Time to Try It Out

With all the pieces in place, the solution is ready for testing; the demo project's readme contains details on launching both the client and the agent. For instance, asking the assistant for flights from Graz to Hamburg causes the agent to request the frontend tool findFlights, and the application navigates to the results list. Questions about specific flights are answered directly in the chat with the flight card registered as a widget:

Der Sidecar-Chat beantwortet eine Fluganfrage mit Text und der interaktiven Flugkarte

The agent also handles lookups of booked flights, as well as bookings and cancellations—these rely on its server-side tools. The default renderer enabled at the start logs those invocations in the chat history, keeping such actions transparent for users.

Summary

@copilotkit/angular makes it straightforward to integrate AG-UI into Angular applications. Client-side tools and widgets are uniformly described as frontend tools, with widgets merely adding a rendering component. Server-side tool calls also appear in the chat history—displayed by CopilotKit's built-in default renderer, activated via the defaultToolRendering option. The agent store exposes the conversation as a signal, and the bundled RenderToolCalls component presents the registered components within the chat.

What Comes Next

With AG-UI, frontend-agent communication is settled—the upcoming series demonstrates how the LLM can assemble the UI itself from layouts, displays, and inputs, eliminating the need for new frontend code per use case.

Next Article →


Interested in Production-Ready Agentic UI Architectures?

In my workshop, we cover AG-UI, A2UI, MCP Apps, HITL patterns, and modern Angular architectures for real-world agentic systems.

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

All Details →

FAQ

How can AG-UI be integrated into Angular?

@copilotkit/angular provides an Angular integration built directly on the AG-UI SDK. It manages the agent, executes client-side tools, and exposes the conversation as a signal-based agent store.

What exactly is an agent store?

The agent store represents a specific agent along with its state. injectAgentStore supplies it as a Signal<AgentStore>, which offers the chat history (messages) and execution status (isRunning) as signals.

What purpose does the initAgentStore helper serve?

CopilotKit currently lacks the ability to initialize agent stores for lazily loaded feature areas at a later stage. initAgentStore bridges this gap: it registers the agent at runtime as a self-managed agent and registers the frontend tools for its agentId.

How do widgets end up in the chat history?

Widgets are frontend tools paired with an Angular component. When the agent requests such a tool, CopilotKit's RenderToolCalls component renders the associated component in the conversation and provides it with parameters passed by the LLM, typed by the Zod schema.

How can server-side tool calls be visualized?

Server-side tool invocations also appear in the chat history, even though the client doesn't execute them. Setting defaultToolRendering: true in provideCopilotKit enables CopilotKit's built-in default renderer, which displays such calls as an expandable card complete with parameters.