Ein Agent, das AG-UI SDK und CopilotKit für Angular
Im letzten Teil haben wir die AG-UI-Nachrichten noch von Hand erzeugt, um das SDK kennenzulernen. In der Praxis kommen diese Nachrichten jedoch von einem echten Agent, der ein Sprachmodell orchestriert, Tools aufruft und sich den Gesprächsverlauf merkt. Genau diese Lücke schließt dieser Artikel: Er zeigt, wie ein mit Mastra entwickelter Agent über AG-UI bereitgestellt und anschließend von zwei Clients konsumiert wird – einem bewusst einfachen Kommandozeilen-Client und einem ersten Angular-Client auf Basis von CopilotKit.
Dieser Teil der Serie erklärt, wie der Agent samt Tool, Prompt und Memory aufgebaut ist, was die AG-UI-Integration von Mastra leistet und warum sich der Client dank AG-UI nicht für das gewählte Agent-Framework interessieren muss.
📂 Source Code (siehe Branch copilotkit)
Ein Agent mit Mastra
Die meisten Agent-Frameworks bieten sehr ähnliche Abstraktionen: einen Agent, der ein Sprachmodell mit einem Prompt versorgt, Tools, die der Agent aufrufen kann, sowie eine Memory für den Gesprächsverlauf. Wir nutzen hier Mastra, weil es von Grund auf für TypeScript entwickelt wurde und sich damit nahtlos in die Welt von Frontend-Teams einfügt. Mit LangGraph, Google ADK oder dem Microsoft Agent Framework sähe das Ergebnis strukturell sehr ähnlich aus.
Mastra und serverseitige Agents sind nicht der Fokus dieser Artikelserie. Ein kurzer Blick auf die Serverseite hilft jedoch zu verstehen, woher die AG-UI-Nachrichten kommen, die unser Client später empfängt. Die folgenden Abschnitte zeigen dafür Schritt für Schritt die Bausteine unseres Beispiel-Agents: das Projekt-Setup, den Agent selbst, seinen Prompt und das Wetter-Tool.
Ein neues Mastra-Projekt anlegen
Ein neues Mastra-Projekt entsteht mit einem einzigen Befehl:
npm create mastra@latestDer interaktive Wizard erzeugt ein lauffähiges Projekt samt Beispiel-Agent. Daneben stellt Mastra auch offizielle Agent Skills bereit: gepackte Anleitungen und Dokumentation für Coding-Assistenten wie Claude Code. Einmal im Projektordner unter .claude/skills abgelegt, versorgen sie den Assistenten mit dem aktuellen API-Stand – hilfreich, da sich Mastra schnell weiterentwickelt und das antrainierte Wissen der Modelle oft veraltet ist.
Mit diesen Skills haben wir in unserem Workspace flights42 ein bewusst einfaches Mastra-Setup generieren lassen. Es findet sich im Ordner ai-demo/server und besteht nur aus einer Handvoll Dateien.
Der Weather-Agent
Das Herzstück ist der Agent, der Prompt, Modell, Tools und Memory zusammenführt:
import { Agent } from '@mastra/core/agent';
import { Memory } from '@mastra/memory';
import { weatherTool } from './weather-tool.js';
export const weatherAgent = new Agent({
id: 'weatherAgent',
name: 'Weather Assistant',
instructions: `[...]`,
model: 'openai/gpt-5.6-luna',
tools: { getWeather: weatherTool },
memory: new Memory(),
});Die Eigenschaft model nutzt Mastras Model-Router: Der String benennt Provider und Modell, den zugehörigen API-Key erwartet Mastra als Umgebungsvariable, im Fall von OpenAI also als OPENAI_API_KEY. Ein Wechsel auf ein anderes Modell oder einen anderen Anbieter reduziert sich damit auf das Ändern dieses Strings.
Unter tools registriert der Agent das weiter unten besprochene weatherTool unter dem Namen getWeather. Das ist der Name, den das Sprachmodell sieht und in Tool Calls verwendet. Die Memory adressiert eine Eigenheit von Sprachmodellen: LLMs sind zustandslos und erinnern sich zwischen zwei Aufrufen an nichts. Wer einen Dialog führen will, muss deshalb bei jedem Aufruf den bisherigen Gesprächsverlauf mitschicken. Genau diese Aufgabe übernimmt die Memory – sie speichert den Verlauf serverseitig und reicht ihn bei jedem Run an das Modell weiter, sodass der Client nur die jeweils neue Frage übermitteln muss.
Der Prompt
Die im Agent hinterlegten instructions bilden den System-Prompt:
You are a friendly weather assistant.
When the user asks about the weather in a city,
look it up. Then answer in one short, natural sentence that mentions the
condition and the temperature in degrees Celsius.Der Prompt legt die Rolle des Agents fest, beschreibt die Aufgabe und gibt das gewünschte Antwortformat vor. Sprachmodelle messen dem System-Prompt dabei eine deutlich höhere Bedeutung bei als den gewöhnlichen Benutzernachrichten, weil sie gezielt darauf trainiert wurden, Anweisungen aus dieser Rolle Vorrang zu geben. Der System-Prompt ist damit der richtige Ort für Verhaltensregeln und Leitplanken – und bietet zumindest ein Stück weit Schutz davor, dass geschickt formulierte Benutzereingaben das Verhalten des Agents umdefinieren.
Bemerkenswert ist außerdem, was der Prompt nicht enthält: Das Tool getWeather wird nicht namentlich erwähnt. Die Formulierung look it up genügt, denn das Modell wählt das passende Tool anhand seiner Beschreibung selbst aus. Wie diese Beschreibung aussieht, zeigt der nächste Abschnitt.
Das Wetter-Tool
Das Tool besteht im Kern aus einem Namen, einer Beschreibung und Schemata. Diese Informationen liefern dem Sprachmodell die Grundlage, um zu entscheiden, wann und wie es das Tool aufruft:
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
import { getWeather } from './weather-api.js';
export const weatherTool = createTool({
id: 'getWeather',
description: 'Returns the current weather and temperature for a given city.',
inputSchema: z.object({
city: z.string().trim().min(1).describe('The name of the city.'),
}),
outputSchema: z.object({
city: z.string(),
condition: z.enum(['rainy', 'sunny', 'cloudy']),
temperature: z.number(),
}),
execute: async ({ city }) => {
return getWeather(city);
},
});Die mit zod definierten Schemata beschreiben die erwarteten Parameter sowie die Struktur des Ergebnisses und werden intern als JSON Schema an das Modell weitergereicht. Die eigentliche Arbeit erledigt execute. Die dort aufgerufene Funktion getWeather simuliert lediglich einen Wetterdienst: Sie würfelt pro Stadt einmalig Wetterlage und Temperatur aus und liefert bei weiteren Anfragen dieselben Werte. Damit läuft die Demo ohne Zugriff auf eine externe Wetter-API.
AG-UI-Endpunkt bereitstellen
Der Agent läuft nun am Server – doch es stellt sich die Frage, wie das Frontend mit ihm sprechen soll. Mastra bringt zwar eigene HTTP-Endpunkte und ein eigenes Client-SDK mit – wer dagegen programmiert, koppelt sein Frontend jedoch an das gewählte Agent-Framework. Ein späterer Wechsel, etwa zu LangGraph, würde dann auf den Client durchschlagen. Genau deshalb schieben wir AG-UI als schmale, standardisierte Taille dazwischen: Der Client kennt nur die im ersten Teil besprochenen Nachrichtentypen und bleibt damit unabhängig von Framework und Modell.
Die Brücke zwischen beiden Welten schlägt das Paket @ag-ui/mastra. Es enthält die Klasse MastraAgent, eine Implementierung des aus dem letzten Teil bekannten AbstractAgent. Sie nimmt einen Mastra-Agent entgegen, ruft ihn bei jedem Run auf und übersetzt dessen Streaming-Ereignisse in AG-UI-Nachrichten wie TOOL_CALL_START oder TEXT_MESSAGE_CONTENT. Der folgende Route-Handler zeigt diesen Adapter im Einsatz:
import type { RunAgentInput } from '@ag-ui/core';
import { MastraAgent } from '@ag-ui/mastra';
import type { ContextWithMastra } from '@mastra/core/server';
import { streamSSE } from 'hono/streaming';
import { concatMap, lastValueFrom } from 'rxjs';
export async function chatRouteHandler(
c: ContextWithMastra,
): Promise<Response> {
const input = (await c.req.json()) as RunAgentInput;
const agent = c.get('mastra').getAgent('weatherAgent');
const aguiAgent = new MastraAgent({ agent, resourceId: input.threadId });
return streamSSE(c, async (sse) => {
const send = (data: unknown): Promise<void> =>
sse.writeSSE({ data: JSON.stringify(data) });
await lastValueFrom(aguiAgent.run(input).pipe(concatMap(send)), {
defaultValue: undefined,
});
});
}Der Handler liest zunächst den RunAgentInput aus dem Body des HTTP-Requests. Das ist genau die Payload, die der HttpAgent des AG-UI SDK versendet – samt threadId, runId und den neuen Nachrichten. Die threadId reicht der Handler als resourceId an den MastraAgent weiter. Darüber ordnet Mastras Memory die einzelnen Runs demselben Gesprächsverlauf zu.
Die Methode run liefert ein Observable mit AG-UI-Nachrichten. Wie im letzten Teil erwähnt, kümmert sich das AG-UI SDK nicht um das Transportprotokoll. Diese Aufgabe übernimmt hier ein wenig Gluecode, der jede Nachricht als Server-sent Event (SSE) in den HTTP-Response-Stream schreibt. Praktischerweise baut Mastras Server auf Hono auf, sodass sich dessen Helfer streamSSE nutzen lässt: Er setzt die nötigen HTTP-Header, hält die Verbindung offen und verpackt jede über writeSSE gesendete Nachricht als SSE-Frame.
Bleibt die Brücke zwischen Observable und Stream: concatMap schickt die Nachrichten der Reihe nach über send, und lastValueFrom wartet, bis das Observable abgeschlossen ist – erst dann endet der Callback und Hono schließt den Stream.
Aus Gründen der Lesbarkeit verzichtet der gezeigte Ausschnitt auf die Fehlerbehandlung; die vollständige Fassung im Repo sendet im Fehlerfall eine RUN_ERROR-Nachricht.
Die Route registrieren
Zu guter Letzt muss der Server die Route noch anbieten. Dazu registriert die zentrale Mastra-Instanz den Agent sowie den Route-Handler:
import { Mastra } from '@mastra/core/mastra';
import { registerApiRoute } from '@mastra/core/server';
import { InMemoryStore } from '@mastra/core/storage';
import { weatherAgent } from './agent.js';
import { chatRouteHandler } from './chat-route.js';
export const mastra = new Mastra({
storage: new InMemoryStore(),
agents: { weatherAgent },
server: {
port: 4555,
cors: {
origin: '*',
},
apiRoutes: [
registerApiRoute('/chat', {
method: 'POST',
handler: chatRouteHandler,
}),
],
},
});Mastra bringt einen eigenen HTTP-Server mit, dem sich über registerApiRoute eigene Endpunkte hinzufügen lassen. Unsere Route POST /chat ist damit der AG-UI-Endpunkt der Anwendung. Der InMemoryStore dient als Ablage für die Memory; in einer Fachanwendung käme hier ein persistenter Store zum Einsatz.
Die CORS-Konfiguration ist für Clients gedacht, die im Browser laufen: Der weiter unten vorgestellte Angular-Client wird von einem anderen Origin aus – dem Dev-Server auf Port 4300 – auf den Endpunkt zugreifen. Für die Demo ist das großzügige origin: '*' in Ordnung; in einer Fachanwendung würde man die erlaubten Origins gezielt einschränken.
Agentic UI with Angular
Wenn du AG-UI 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.
Der AG-UI-Client
Zur Vereinfachung und um den Blick aufs Wesentliche zu lenken, starten wir mit einer Kommandozeilen-Anwendung. Sie findet sich im Ordner ai-demo/client und nutzt dieselben Bausteine des AG-UI SDK, die später auch im Browser zum Einsatz kommen. Bemerkenswert ist vor allem, was der Client nicht enthält: keine einzige Zeile Mastra-spezifischen Code. Er kennt lediglich die URL des Endpunkts und die AG-UI-Nachrichtentypen.
import { type AgentSubscriber, HttpAgent, randomUUID } from '@ag-ui/client';
const threadId = randomUUID();
const agent = new HttpAgent({
url: 'http://localhost:4555/chat',
threadId,
});Der aus dem letzten Teil bekannte HttpAgent erhält die URL des Endpunkts sowie eine threadId, die der Client einmal pro Sitzung generiert. Die Funktion ask reicht eine einzelne Frage an den Agent weiter:
async function ask(prompt: string): Promise<void> {
agent.addMessage({
id: randomUUID(),
role: 'user',
content: prompt,
});
const subscriber: AgentSubscriber = {};
if (SHOW_DETAILS) {
subscriber.onEvent = ({ event }) => logEvent(event);
} else {
subscriber.onTextMessageContentEvent = ({ event }) => {
stdout.write(event.delta);
};
}
await agent.runAgent({ runId: randomUUID() }, subscriber);
}Da sich der Agent den Verlauf serverseitig merkt, genügt es, wenn ask pro Frage nur die neue Benutzernachricht über addMessage hinterlegt und anschließend mit runAgent einen neuen Run startet.
Der AgentSubscriber verarbeitet die eintreffenden Nachrichten. Im Normalfall reicht dafür ein einziger Handler: onTextMessageContentEvent schreibt jedes empfangene Textfragment direkt auf die Konsole, sodass die Antwort Wort für Wort erscheint – Streaming in seiner einfachsten Form. Startet man den Client hingegen mit dem Flag --details, protokolliert der generische Handler onEvent stattdessen jede einzelne AG-UI-Nachricht.
Fehlt noch die Benutzereingabe. Eine kleine Hilfsfunktion auf Basis von Nodes readline-API zeigt einen Prompt an und liefert die eingegebene Zeile zurück:
import { stdin, stdout } from 'node:process';
import { createInterface } from 'node:readline/promises';
const rl = createInterface({ input: stdin, output: stdout, terminal: false });
export async function readLine(label: string): Promise<string> {
return (await rl.question(label)).trim();
}Eine Endlosschleife verdrahtet schließlich beides: Sie liest Zeile für Zeile von der Konsole und reicht jede Eingabe an ask weiter:
async function main(): Promise<void> {
console.log('Ask about the weather in a city.');
for (;;) {
const prompt = await readLine('\n> ');
if (!prompt) {
continue;
}
await ask(prompt);
}
}
main();Leere Eingaben überspringt die Schleife einfach. Auf einen ordentlichen Ausstieg verzichtet der gezeigte Ausschnitt bewusst – Ctrl+C genügt hier völlig. Die Fassung im Repo unterstützt zusätzlich das Kommando exit.
Die Anwendung testen
Zum Ausprobieren braucht es zwei Terminals im Root des Workspace sowie einen API-Key als Umgebungsvariable OPENAI_API_KEY. Das erste Terminal startet den Mastra-Server, der auf Port 4555 lauscht:
npm run ai-demo-serverDas zweite Terminal startet den Client:
npm run ai-demo-clientNun lässt sich zum Beispiel nach dem Wetter in Wien fragen:

Dank der serverseitigen Memory funktionieren auch Anschlussfragen wie Is it warmer than in Paris? – der Agent bezieht die Frage korrekt auf Wien, da beide Fragen über dieselbe threadId demselben Gesprächsverlauf zugeordnet werden.
Richtig interessant wird der Blick hinter die Kulissen. Startet man den Client im Detail-Modus und fragt zum Beispiel nach dem Wetter in Graz, zeigt er die einzelnen AG-UI-Nachrichten an, die der Adapter aus der Arbeit des Mastra-Agents erzeugt:
npm run ai-demo-client -- --details[RUN_STARTED ]
[TOOL_CALL_START ] getWeather
[TOOL_CALL_ARGS ] {"city":"Graz"}
[TOOL_CALL_END ]
[TOOL_CALL_RESULT ] {"city":"Graz","condition":"sunny","temperature":17}
[TEXT_MESSAGE_START ]
[TEXT_MESSAGE_CONTENT] G
[TEXT_MESSAGE_CONTENT] raz
[TEXT_MESSAGE_CONTENT] is
[TEXT_MESSAGE_CONTENT] sunny
[...]
[TEXT_MESSAGE_END ]
[RUN_FINISHED ]Hier zeigen sich die aus dem ersten Teil bekannten Nachrichtentypen in Aktion: Der Run startet, das Modell fordert einen Tool Call für getWeather an, der Agent führt das Tool serverseitig aus und protokolliert das Ergebnis. Anschließend trifft die Antwort als Strom einzelner Textfragmente ein, bevor der Run abgeschlossen wird. Diesmal stammen die Nachrichten allerdings nicht aus hartcodierten Beispielen, sondern von einem echten Agent samt Sprachmodell.
Ein Angular-Client mit CopilotKit
Nach der Kommandozeile werfen wir noch einen ersten Blick auf das eigentliche Zielumfeld dieser Serie: den Browser. Der Workspace enthält dazu unter projects/simple-client eine bewusst minimale Angular-Anwendung, die denselben AG-UI-Endpunkt anspricht wie der Kommandozeilen-Client.
Zum Einsatz kommt dabei CopilotKit – jenes Open-Source-Frontend-SDK für AI-Assistenten und Copilots, dessen Macher auch hinter AG-UI stehen. CopilotKit bringt fertige UI-Bausteine für Chats mit und spricht nativ AG-UI. Die Angular-Unterstützung kommt als eigenes Paket via npm:
npm i @copilotkit/angularDie Verbindung zu unserem Agent stellt provideCopilotKit in der app.config.ts her:
import { HttpAgent } from '@ag-ui/client';
import { provideCopilotKit } from '@copilotkit/angular';
const chatUrl = 'http://localhost:4555/chat';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideCopilotKit({
agents: {
weatherAgent: new HttpAgent({ url: chatUrl }),
},
}),
],
};Hier zeigt sich erneut der Charme von AG-UI: CopilotKit erwartet schlicht einen AG-UI-Agent. Zum Einsatz kommt daher der bereits bekannte HttpAgent, der auf unseren /chat-Endpunkt verweist. Der Schlüssel weatherAgent vergibt einen clientseitigen Namen, über den sich Komponenten auf diesen Agent beziehen.
Die eigentliche Oberfläche liefert das Chat-Control CopilotChat:
import { CopilotChat } from '@copilotkit/angular';
@Component({
selector: 'app-root',
imports: [CopilotChat],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}<main class="chat-container">
<copilot-chat agentId="weatherAgent" />
</main>Mehr braucht es nicht: copilot-chat bringt Nachrichtenliste, Eingabefeld und Streaming-Anzeige mit und wickelt die AG-UI-Kommunikation im Hintergrund ab. Das Attribut agentId verweist auf den zuvor registrierten Agent. Der folgende Befehl startet die Anwendung über den Angular-Dev-Server auf http://localhost:4300:
npm run simple-client
Auch hier funktionieren Anschlussfragen wie Is it warmer than in London?, denn hinter dem Chat steht derselbe Agent samt serverseitiger Memory.
Das fertige Chat-Control ist der schnellste Weg zu einem lauffähigen Ergebnis und damit ideal, um rasch ins Fahren zu kommen. In den kommenden Teilen dieser Serie setzen wir hingegen auf den Headless-Mode von CopilotKit: Er stellt Zustand und Verhalten des Chats bereit, überlässt die Darstellung aber vollständig der Anwendung. Das ist immer dann wichtig, wenn der Chat zum eigenen Design passen soll, eigene Widgets im Verlauf erscheinen oder Tool Calls mit Stores, Formularen und dem Routing der Anwendung interagieren sollen.
Zusammenfassung
Ein Agent-Framework wie Mastra reduziert die Serverseite auf wenige, gut lesbare Bausteine: ein Agent mit Prompt und Memory, ein Tool mit Schemata sowie eine zentrale Konfiguration. Der Adapter aus @ag-ui/mastra übersetzt die Arbeit dieses Agents in AG-UI-Nachrichten, und ein wenig Gluecode streamt sie als Server-sent Events an den Client.
Der entscheidende Punkt ist die Entkopplung: Der Client kennt weder Mastra noch das verwendete Sprachmodell. Er spricht ausschließlich AG-UI. Das gilt für die Kommandozeile genauso wie für den Angular-Client, der dank des Chat-Controls von CopilotKit mit wenigen Zeilen Code entsteht. Damit ließe sich das Agent-Framework austauschen, ohne eine einzige Zeile Client-Code anzufassen – genau das Versprechen, mit dem diese Serie gestartet ist.
Der nächste Schritt
Mit dem fertigen Chat-Control von CopilotKit sind wir schnell zu einem lauffähigen Ergebnis gekommen. Ab dem nächsten Teil setzen wir dagegen auf den bereits erwähnten flexiblen Headless-Mode von CopilotKit und zeigen, wie sich Agents damit idiomatisch und tief in Angular-Anwendungen integrieren lassen – mit eigener Darstellung des Chatverlaufs, clientseitigen Tools und eigenen Widgets.
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
Warum nutzt der Artikel Mastra?
Mastra wurde von Grund auf für TypeScript entwickelt und fügt sich damit gut in den Werkzeugkasten von Frontend-Teams ein. Die gezeigten Konzepte – Agent, Tools, Memory und Prompt – finden sich aber in sehr ähnlicher Form auch in anderen Agent-Frameworks wie LangGraph, Google ADK oder dem Microsoft Agent Framework.
Was macht der Adapter aus @ag-ui/mastra?
Die Klasse MastraAgent implementiert den AbstractAgent des AG-UI SDK. Sie ruft den übergebenen Mastra-Agent auf und übersetzt dessen Streaming-Ereignisse in AG-UI-Nachrichten wie TOOL_CALL_START oder TEXT_MESSAGE_CONTENT. Damit entfällt das manuelle Erzeugen dieser Nachrichten.
Muss der Client wissen, dass Mastra im Spiel ist?
Nein. Der Client kennt nur die URL des AG-UI-Endpunkts und die standardisierten Nachrichtentypen. Das Agent-Framework, das Sprachmodell und die serverseitigen Tools lassen sich austauschen, ohne den Client zu ändern.
Was ist CopilotKit?
CopilotKit ist ein Open-Source-Frontend-SDK für AI-Assistenten und agentische UIs, hinter dem dieselben Macher wie hinter AG-UI stehen. Es bietet fertige Bausteine wie das gezeigte Chat-Control sowie einen Headless-Mode für vollständig eigene Oberflächen und kommuniziert mit Agents nativ über AG-UI.
Wie merkt sich der Agent den Gesprächsverlauf?
Der Client generiert pro Sitzung eine threadId, die der Route-Handler als resourceId an Mastras Memory weiterreicht. Dadurch ordnet der Agent alle Runs demselben Verlauf zu, und der Client muss nur die jeweils neuen Nachrichten übermitteln.


