Wenn über Guardrails für Agenten gesprochen wird, geht es meist um Checks: Input- und Output-Validierung, Moderation, Judges. Sie alle greifen zur Laufzeit ein: Sie prüfen, was ins Modell hineingeht oder aus ihm herauskommt, und reagieren auf Unerwünschtes. Eine Kategorie kommt dabei regelmäßig zu kurz: Architekturentscheidungen, die unerwünschtes Verhalten strukturell ausschließen. Wo das Modell etwas gar nicht ausdrücken kann, muss es niemand prüfen. Wo eine Information nie übertragen wird, kann sie niemand manipulieren. Und wo ein Ablauf in Code gegossen ist, kann das Modell ihn nicht durcheinanderbringen. Die beste Fehlerbehandlung ist die, die ein Fehler gar nicht erst erreicht.
Dieser Artikel sammelt solche Entscheidungen anhand unserer Demo-Anwendung Flight42, die wir zur Veranschaulichung von Agentic UI mit Angular verwenden. Dazu gehört eine anwendungsspezifische DSL statt generiertem Markup, minimaler Kontexttransfer, die Granularität von Tools, Workflows mit deterministischen Schritten – und Sandboxes, die das Modell Code schreiben lassen statt Ergebnisse. Der Client basiert auf Angular und CopilotKit, die Kommunikation läuft über AG-UI, die Agents sind mit Mastra umgesetzt.
📂 Quellcode (siehe Branch copilotkit)
Eine DSL statt generiertem Markup
Die erste Entscheidung betrifft das generative Dashboard der Demo. Der Benutzer beschreibt in freiem Text, was er sehen will – „meine Bordkarten, die gebuchten Flüge und das Wetter" –, und die Anwendung stellt die passende Oberfläche zusammen. Technisch basiert das auf A2UI, einem Protokoll, das Oberflächen als strukturierte Daten beschreibt, die der Client rendert. Eine Einführung in A2UI gibt mein Artikel zu diesem Thema.
Der naheliegende Ansatz: Das Modell erzeugt das A2UI-Markup direkt. Genau so sah unsere erste Version aus – und sie brauchte für ein einziges Dashboard 37 Sekunden und über 46.000 Tokens. Das Modell musste Token für Token eine umfangreiche Struktur produzieren, nebenbei Tool-Aufrufe für die Daten absetzen und kam dabei gelegentlich durcheinander, was Korrekturschleifen erzwang. Wie wir das um den Faktor 300 beschleunigt haben, beschreibt ein eigener Artikel im Detail; hier interessiert uns die Architekturentscheidung dahinter.
Sie lautet: Das Modell erzeugt kein Markup mehr, sondern nur noch eine kleine, anwendungsspezifische DSL – eine kompakte Beschreibung, welche Kacheln das Dashboard zeigen soll:
{
"tiles": [
{ "type": "boardingPasses", "count": 2 },
{ "type": "bookedFlightsList", "showCheckInButton": true },
{ "type": "flightSearch", "defaultFrom": "Graz", "defaultTo": "Hamburg" },
{ "type": "rentalCars" },
{ "type": "hotels" },
{ "type": "weatherList" }
]
}Definiert ist diese DSL als Zod-Schema. Der Agent ist auf einen einzigen Schritt begrenzt: Er übersetzt die Anfrage in die DSL, mehr nicht. Wie die DSL aufgebaut ist, erfährt das Modell über Beispiele im Systemprompt.
Die generierte DSL übergibt das Modell an das Tool renderDashboard. Dieses validiert die generierte DSL gegen das Zod-Schema und Verstöße gehen als Fehler an das Modell zurück, das seinen Aufruf daraufhin korrigieren kann.
Die eigentliche Arbeit übernimmt die Route: Sie fängt die Argumente des Aufrufs ab, kompiliert die DSL deterministisch in die vollständige A2UI-Oberfläche und leitet daraus auch gleich ab, welche Daten zu laden sind – ganz ohne weiteres Reasoning des Modells. Weil die DSL kompakt ist, lässt sie sich zudem hervorragend cachen: Für ein bereits bekanntes Dashboard wird das Modell gar nicht mehr aufgerufen.
Veraltete Inhalte sind dabei nicht zu befürchten, denn A2UI trennt dank Datenbindung Struktur und Daten: Aus dem Cache kommt nur die Struktur der Oberfläche, die daran gebundenen Daten ruft die Anwendung bei jedem Aufruf frisch ab.
Das Ergebnis sieht für den Benutzer aus wie zuvor:

Der ursprüngliche Antrieb für diesen Umbau war Performance. Der zweite Gewinn ist aber mindestens so wertvoll: Kontrolle. Das Modell kann schlicht nichts ausdrücken, was die DSL nicht vorsieht – kein unerwartetes Layout, keine erfundenen Komponenten, keine eingeschleusten Inhalte. Jeder dieser Fälle müsste beim direkten Generieren von Markup durch Output-Checks abgefangen werden; hier gibt es sie als Möglichkeit gar nicht. Der Preis ist die Kehrseite derselben Medaille: Auf Wünsche, die die DSL nicht vorsieht, kann die Lösung nicht reagieren. Für die meisten Geschäftsanwendungen ist das ein guter Tausch, denn dort zählt Vorhersehbarkeit mehr als unbegrenzte Dynamik.
Kontext, der nie auf die Reise geht
Die zweite Entscheidung betrifft nicht das, was das Modell erzeugt, sondern das, was es zu sehen bekommt.
A2UI erlaubt sogenannte Custom Catalogs: Der Client kündigt eigene UI-Komponenten an – Namen, Schemata, Beschreibungstexte –, damit der Agent sie in generierten Oberflächen verwenden kann. Die Bordkarten im Dashboard des vorigen Abschnitts sind genau so ein Fall: Dahinter steckt mit dem TicketWidget eine Angular-Komponente des Clients, die der Agent lediglich beim Namen nennt.
In der Demo reisen die Katalogbeschreibungen als Kontext-Eintrag vom Client zum Server und landen dort als Abschnitt im System- oder Developerprompt des Agents. Das ist zwar für die lokale Entwicklung bequem, öffnet aber einen Kanal: Ein manipulierter Client kann dem Agent präparierte Komponentenbeschreibungen unterschieben – und ein Beschreibungstext im System- oder Developerprompt ist eine ideale Stelle für eine Prompt Injection.
Die Architektur-Antwort darauf: Der Client überträgt nur noch die Id des Katalogs. In der Demo genügt dafür ein Flag beim Registrieren des Katalogs:
// src/app/app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
[...]
provideA2uiCatalog(customCatalog, {
sendCatalogDescription: false
}),
[...]
],
};Der Renderer bekommt den Katalog weiterhin vollständig – lokal rendert die Anwendung unverändert. Allerdings übergibt nun der Client nur mehr die Catalog Id an den Server. Die Schemata und Beschreibungen bezieht der Server aus einer vertrauenswürdigen Registry, die er selbst kontrolliert – im einfachsten Fall eine Map, die Katalog-Ids auf hinterlegte Katalogbeschreibungen abbildet. Damit verschwindet der Angriffskanal vollständig – nicht, weil ein Filter die Beschreibungen prüft, sondern weil sie den Client nie verlassen. Was nicht übertragen wird, kann unterwegs nicht manipuliert werden. Das ist dasselbe Least-Privilege-Prinzip, das man von Tool-Ausstattungen kennt, angewandt auf den Kontext: So wenig wie möglich vom Client entgegennehmen, so viel wie möglich aus Quellen beziehen, denen der Server vertraut.
Tool-Granularität als Architekturentscheidung
Auch der Zuschnitt der Tools bestimmt, wie viel Raum für Fehler das Modell überhaupt hat – in beide Richtungen.
Ein Beispiel für bewusst feine Granularität liefert das Co-Planning der Demo: Der Benutzer und der Agent bearbeiten gemeinsam einen Aktionsplan, und für Änderungen bietet der Client dem Modell gezielte Frontend-Tools an – swapPlanSteps tauscht zwei Schritte, movePlanStep verschiebt einen, removePlanStep entfernt einen. Jedes dieser Tools ist eine kleine, klar umrissene Operation, die Schritte über ihre stabilen Ids adressiert:

Ein Tool, das mit zwei stabilen Ids arbeitet, lässt dem Modell kaum Raum für Fehler. Die Alternative – das Modell gibt bei jeder Änderung den ganzen Plan neu aus – klingt einfacher, ist aber die fehleranfälligste Variante überhaupt: Beim Neuausgeben können Schritte verloren gehen, Details mutieren, Formulierungen driften. Die feinen Tools schließen diese Fehlerklasse aus, weil der Plan selbst nie durch das Modell wandert; er liegt im Anwendungszustand und das Modell äußert nur Änderungswünsche.
Dieselbe Logik lässt sich in die Gegenrichtung drehen. Für komplexe Aktionen mit fester Choreografie – etwa das Umbuchen eines Flugs: stornieren plus neu buchen, in der richtigen Reihenfolge, mit Rollback im Fehlerfall – ist ein bewusst grobes Tool die sicherere Wahl: eine Transaktion im Code statt einer Choreografie im Modell.
Das Entscheidungskriterium ist in beiden Fällen dasselbe: Freiheitsgrade gehören dorthin, wo Urteilsvermögen gefragt ist – und nirgendwo sonst. Welche Schritte getauscht werden, entscheidet das Modell; wie eine Umbuchung transaktional korrekt abläuft, entscheidet Code.
Agentic UI with Angular
Wenn du solche Architekturentscheidungen nicht nur punktuell treffen, sondern systematisch in größere Anwendungen einbetten willst:
In meinem Buch Agentic UI mit Angular gehe ich genau auf diese Patterns und Trade-offs im Detail ein.
Workflows: Das Modell versteht, der Code arbeitet
Die bisher gezeigten Entscheidungen begrenzen einzelne Interaktionen. Die nächste betrifft ganze Abläufe – und sie ist die vielleicht wichtigste Architekturentscheidung in agentischen Systemen: die Aufteilung eines Ablaufs in agentische und deterministische Schritte.
Der Travel Planner der Demo stellt Pauschalreisen zusammen – Flüge und Hotels für eine Route wie "Graz nach Rom, mit Abendessen-Stopp in Wien". Dass dafür zuerst Flüge, dann Hotels geladen werden und am Ende ein Gesamtplan entsteht, steht fest, bevor die erste Anfrage eintrifft. Es gibt keinen Grund, diese Reihenfolge bei jedem Aufruf neu vom Modell erfinden zu lassen – und jede Menge Gründe dagegen: Reproduzierbarkeit, Testbarkeit, Kosten.
Die Demo teilt die Arbeit deshalb bewusst auf:
- Ein Agent mit einem günstigen, schwachen Modell extrahiert aus der freien Benutzeranfrage die Kriterien – einen groben Plan aus Flugstrecken und Übernachtungsstädten. Reines Sprachverständnis, keine Entscheidungen.
- Deterministische Workflow-Schritte laden mit diesen Kriterien die Daten: Flugkandidaten je Strecke, Hotels je Stadt. Kein Modell weit und breit.
- Ein Finalizer-Agent mit einem stärkeren Modell trifft die eigentliche Auswahl aus den Kandidaten – die einzige Stelle, an der Urteilsvermögen zählt.
- Code validiert die Auswahl, bevor sie den Benutzer erreicht.

Die ersten beiden Workflow-Schritte sind gewöhnlicher TypeScript-Code: Eine Schleife über die Strecken des Grobplans holt je Strecke die Flugkandidaten – ein direkter Service-Aufruf ohne Umweg über ein Modell –, und analog lädt der zweite Schritt die Hotel-Optionen je Übernachtungsstadt.
Erst der letzte Schritt holt wieder ein Modell ins Boot. Es wägt Preis gegen Ankunftszeit und Hotellage gegen Sternekategorie ab; seine Antwort wird gegen ein Zielschema validiert. Und selbst ihm wird nicht blind vertraut: Anschließend prüft Code die Auswahl deterministisch nach – jeder gewählte Flug muss tatsächlich unter den Kandidaten der jeweiligen Strecke sein, jedes Hotel unter den Optionen seiner Stadt, andernfalls greift ein Fallback auf den ersten Kandidaten. Halluzinierte Flugnummern sind damit strukturell ausgeschlossen: Das Modell wählt aus, aber die Auswahlmenge definiert der Code. Genau darin liegt der Grund für den ganzen Workflow-Umweg – er schafft die Stellen, an denen Code das Modell kontrollieren kann: vor dem Modellaufruf durch kuratierte Kandidaten, danach durch validierte Auswahl.
Für den Benutzer wird diese Struktur sichtbar, denn die Schrittgrenzen wandern über AG-UI-Events in den Client, der daraus eine Fortschrittsanzeige baut:

Die Arbeitsteilung zahlt sich dreifach aus. Kosten: Zwei von drei Schritten verbrauchen gar keine Tokens, und die Kriterien-Extraktion läuft auf dem Mini-Modell. Qualität: Der Finalizer sieht nur kuratierte Kandidaten statt der halben Datenbank. Und Verlässlichkeit: Reihenfolge, Datenbeschaffung und Validierung sind Code – testbar mit ganz gewöhnlichen Unit-Tests.
Deterministik endet nicht am Server
Die bisherigen Entscheidungen leben alle im Backend. Die Geisteshaltung dahinter – dem Modell nur dort vertrauen, wo es keine deterministische Alternative gibt – endet aber nicht an der API-Grenze. Ein Blick in den Angular-Client zeigt dasselbe Muster eine Etage höher im Stack.
Der Travel Planner rendert seinen Reiseplan aus Tool-Aufrufen des Modells und könnte sich schlicht darauf verlassen, dass das Modell die Hotels in der passenden Reihenfolge neben den Flügen ausgibt. Tut er aber nicht: Sein Store ordnet die Hotels deterministisch entlang der Route – und zwar nicht nur beim ersten Setzen des Plans, sondern bei jeder Planänderung:
// src/app/domains/ticketing/feature-travel-planner/travel-plan-store.ts
withMethods((store) => ({
setPlan(plan: TravelPlan): void {
patchState(store, {
summary: plan.summary,
flights: plan.flights,
hotels: orderHotelsByRoute(plan.hotels, plan.flights),
});
},
addFlight(flight: FlightInfo): void {
patchState(store, (state) => {
const flights = upsertById(state.flights, flight);
return { flights, hotels: orderHotelsByRoute(state.hotels, flights) };
});
},
[...]
})),Die Hilfsfunktion orderHotelsByRoute ordnet jedes Hotel der ersten Flugstrecke zu, die in seiner Stadt ankommt. Die Reihenfolge, die der Benutzer sieht, ist damit Code, nicht Modell-Output:

Das ist dasselbe Muster wie bei der DSL des Dashboards und bei der nachgelagerten Prüfung im Workflow: Das Modell liefert Inhalte, aber die Struktur – Reihenfolge, Zuordnung, Konsistenz – stellt deterministischer Code sicher. Wer Agentic UIs baut, sollte den eigenen Client genauso behandeln wie den Server: Verlässlichkeit ist Code-Sache, auch im Frontend.
Die Sandbox: den Code erzeugen lassen, nicht das Ergebnis
Die letzte Entscheidung ist die weitreichendste – und auf den ersten Blick die kontraintuitivste. Die Reporting-Seite der Demo beantwortet natürlichsprachliche Fragen über Flugdaten mit einem Diagramm:

Der naheliegende Weg wäre, dem Modell die Flugdaten in den Kontext zu geben und es die Anteile selbst ausrechnen zu lassen. Frontier-Modelle sind darin besser geworden, als ihr Ruf vermuten lässt – verlassen sollte man sich darauf trotzdem nicht. Die Zuverlässigkeit sinkt mit der Datenmenge, Arithmetik bleibt wegen der tokenbasierten Verarbeitung eine strukturelle Schwäche, und das eigentliche Problem ist nicht die Fehlerquote, sondern ihre Unsichtbarkeit: Einer falsch berechneten Zahl sieht niemand an, dass sie falsch ist. Dazu kommt die Ökonomie – jede Datenzeile müsste durch das Kontextfenster wandern und kostet Tokens und Latenz.
Wofür Sprachmodelle dagegen nachweislich stark sind, ist das Schreiben von Code. Also drehen wir die Aufgabe um: Das Modell rechnet nicht, es beschreibt, wie gerechnet wird – als kleines JavaScript-Snippet. Die Ausführung übernimmt deterministischer Code, das Ergebnis ist reproduzierbar, und das Snippet selbst bleibt einsehbar.
LLM-generierten Code auf dem eigenen Server auszuführen, verlangt allerdings eine Isolationsschicht, denn der Code ist transitiv aus einer Benutzereingabe entstanden – wer den Prompt kontrolliert, beeinflusst den Code. Die Demo setzt auf quickjs-emscripten: die JavaScript-Engine QuickJS, nach WebAssembly kompiliert. Der vom LLM generierte Code läuft in einer vollwertigen, aber leeren JavaScript-Welt: Kommunikation nach außen ist gar nicht vorgesehen – kein Netzwerkzugriff, kein Dateisystem, kein Zugriff auf den umgebenden Server-Prozess und seine Konfiguration. Die einzigen Türen sind Host-Funktionen, die wir explizit hineinreichen.
Das Server-Tool executeJavaScript nimmt den vom LLM generierten Code entgegen, führt ihn in der Quickjs Emscripten-Sandbox aus und kommt so zu den Name/Wert-Paaren für das Balkendiagramm:
// ai-server/src/mastra/tools/execute-javascript.ts
const dataItemSchema = z.object({
name: z.string(),
value: z.number(),
});
const dataItemsSchema = z.array(dataItemSchema);
export const executeJavaScriptTool = createTool({
id: 'executeJavaScript',
description: `
Runs a snippet of JavaScript inside a hardened QuickJS sandbox to
aggregate flight data into chart-ready \`{ name, value }\` pairs. [...]
`,
inputSchema: z.object({
code: z.string().describe(
`
Module body. Use \`await loadFlights(from, to)\` to load flights
for each connection, aggregate into \`{ name, value }[]\`, then
call \`submitResult(items)\` exactly once.
`,
),
title: z.string().describe('Human-readable chart title.'),
}),
outputSchema: z.object({
data: dataItemsSchema,
code: z.string(),
title: z.string(),
}),
execute: async ({ code, title }) => {
let captured: z.infer<typeof dataItemsSchema> = [];
await runSandbox(code, {
functions: {
loadFlights: (from: string, to: string) => {
return fetchFlights(from, to);
},
submitResult: (items: z.infer<typeof dataItemsSchema>) => {
captured = items;
},
},
});
return { data: captured, code, title };
},
});Die Hilfsfunktion runSandbox kapselt die low-level API von quickjs-emscripten und macht sie zugänglicher.
In diesem Beispiel zeigt sich, wie schmal diese Türen ausfallen: loadFlights delegiert an jene API, die auch die reguläre Flugsuche verwendet. Der generierte Code kann also nur Flugdaten laden, nichts anderes. submitResult wiederum ist der einzige Rückkanal: Das Snippet liefert sein Ergebnis durch genau einen Aufruf dieser Funktion.
Bemerkenswert ist der Datenfluss: Die rohen Flugdaten existieren ausschließlich innerhalb der Sandbox-Ausführung. Das Modell bekommt sie nie zu sehen – es erhält als Tool-Ergebnis nur das fertige Aggregat, ein paar Dutzend Bytes statt Hunderter Datenzeilen. Die Sandbox ist damit nicht nur eine Sicherheits-, sondern auch eine Kostengrenze. Und über den Details-Button lässt sich nachlesen, welchen Code das Modell geschrieben hat – Transparenz, die ein direkt rechnendes Modell nie bieten könnte:

Wer den Gedanken der minimalen Angriffsfläche weitertreiben will, muss nicht einmal eine allgemeine Programmiersprache zulassen: Für Aggregationen genügt oft ein einzelner Ausdruck in einer Abfragesprache wie JSONata – ohne Schleifen, ohne Seiteneffekte, ohne JavaScript-Engine. Der Trade-off ist derselbe wie bei der Dashboard-DSL: weniger Ausdrucksstärke gegen mehr Kontrolle.
Zusammenfassung
Alle gezeigten Entscheidungen folgen demselben Prinzip: Sie verkleinern den Möglichkeitsraum des Modells, statt seine Ausgaben zu prüfen. Die DSL nimmt dem Modell die Möglichkeit, unerwartetes Markup zu erzeugen. Der minimale Kontexttransfer nimmt einem manipulierten Client die Möglichkeit, Anweisungen einzuschleusen. Granulare Tools nehmen dem Modell die Möglichkeit, beim Umformulieren eines Plans Schritte zu verlieren – und grobe, transaktionale Tools die Möglichkeit, eine Choreografie falsch abzuwickeln. Der Workflow legt Reihenfolge und Auswahlmengen in Code, sodass halluzinierte Flugnummern strukturell ausscheiden. Der Client ordnet, was er anzeigt, selbst. Und die Sandbox gibt generiertem Code eine leere Welt mit exakt zwei Türen – das Modell beschreibt, wie gerechnet wird, statt selbst zu rechnen.
Architektur ersetzt die übrigen Guardrails nicht: Input- und Output-Checks, Human-in-the-Loop-Patterns, Freigaben und Budgets behalten ihren Platz. Aber sie bestimmt, wie viel diese Checks überhaupt noch abfangen müssen. Wer zuerst die Architektur befragt – kann das Modell diesen Fehler überhaupt machen? –, braucht danach weniger Prüfcode, weniger Threshold-Tuning und weniger Vertrauen in die Tagesform eines nicht-deterministischen Systems. Die Leitlinie dahinter ist einfach: Das Modell versteht Intention; Strukturen, Abläufe und Garantien liefert der Code.
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 bedeutet „Architecture as a Guardrail"?
Architekturentscheidungen, die unerwünschtes Verhalten eines Agents strukturell ausschließen, statt es nachträglich zu erkennen: eine DSL, die nur Vorgesehenes ausdrücken kann; Kontext, der nie übertragen wird; Tools, deren Zuschnitt Fehler unmöglich macht; Workflows, die Reihenfolge und Auswahlmengen in Code festlegen; Sandboxes, die generiertem Code nur explizit definierte Türen öffnen.
Worin unterscheiden sich Architektur-Guardrails von Input- und Output-Checks?
Checks beobachten und greifen ein: Sie prüfen Nachrichten oder Antworten und blocken, filtern oder schreiben um – deterministisch oder modellbasiert. Architektur-Guardrails setzen früher an: Sie formen das System so, dass die Fehlerklasse gar nicht auftreten kann. Beides ergänzt sich – je mehr die Architektur ausschließt, desto weniger bleibt für Checks zu tun.
Wie macht eine DSL die Ausgaben eines LLM kontrollierbarer?
Das Modell übersetzt die Benutzeranfrage nur noch in eine kompakte, per Zod-Schema definierte Beschreibung; die eigentliche Struktur – etwa das A2UI-Markup eines Dashboards – erzeugt deterministischer Code daraus. Das Modell kann damit nichts ausdrücken, was die DSL nicht vorsieht. Nebeneffekte: deutlich schnellere Generierung, weniger Tokens und Cache-Fähigkeit. Der Preis ist reduzierte generative Flexibilität.
Warum kombiniert der Workflow ein schwaches und ein starkes Modell?
Weil die Aufgaben unterschiedlich anspruchsvoll sind: Städte und Daten aus einem Satz zu extrahieren, schafft ein günstiges Mini-Modell zuverlässig; die Abwägung zwischen Kandidaten verlangt Urteilsvermögen und damit ein starkes Modell. Dazwischen laden deterministische Schritte die Daten ganz ohne Modell, und nach der Auswahl validiert Code das Ergebnis gegen die Kandidatenlisten – halluzinierte Auswahl ist damit ausgeschlossen.
Warum lässt man das Modell Code schreiben, statt das Ergebnis direkt berechnen zu lassen?
Weil Sprachmodelle im Codeschreiben nachweislich stark, in Arithmetik über größere Datenmengen aber unzuverlässig sind – und falsche Zahlen unsichtbar falsch sind. Generierter Code läuft dagegen deterministisch, ist einsehbar und reproduzierbar. In einer Sandbox wie QuickJS (per WebAssembly isoliert, mit Zeit- und Speicherlimits und ausschließlich explizit hineingereichten Host-Funktionen) bleibt dabei auch die Angriffsfläche minimal, und die Rohdaten müssen nie durch das Kontextfenster.


