Architecture as a Guardrail: Structurally Ruling Out Entire Classes of Errors

  1. Understanding AG-UI: The Standard for Agentic User Interfaces
  2. AG-UI in Practice: The SDK for TypeScript
  3. AG-UI End to End: Connecting Server and Client
  4. Agentic UI with Angular, CopilotKit, and AG-UI
  5. A2UI: How AI Generates Dynamic UIs at Runtime
  6. Integrating A2UI with AG-UI and CopilotKit in Angular
  7. Custom Catalogs in A2UI: Your Own Components for AI-Generated UIs
  8. A2UI with a DSL: Controllable Dashboards Optimized for Performance
  9. Architecture as a Guardrail: Structurally Ruling Out Entire Classes of Errors

When people talk about guardrails for agents, they usually mean checks: input and output validation, moderation, judges. All of them intervene at runtime: they inspect what goes into the model or comes out of it, and react to anything undesirable. One category regularly gets short shrift: architectural decisions that structurally rule out unwanted behavior. Where the model cannot express something in the first place, nobody has to check for it. Where a piece of information is never transmitted, nobody can manipulate it. And where a sequence is cast in code, the model cannot get it out of order. The best error handling is the kind an error never reaches.

This article collects such decisions using our demo application Flight42, which we use to illustrate Agentic UI with Angular. They include an application-specific DSL instead of generated markup, minimal context transfer, the granularity of tools, workflows with deterministic steps – and sandboxes that let the model write code rather than results. The client is based on Angular and CopilotKit, communication runs over AG-UI, and the agents are implemented with Mastra.

📂 Source code (see branch copilotkit)

A DSL Instead of Generated Markup

The first decision concerns the demo's generative dashboard. The user describes in free text what they want to see – "my boarding passes, the booked flights, and the weather" – and the application assembles the matching user interface. Technically, this is based on A2UI, a protocol that describes user interfaces as structured data which the client renders. For an introduction to A2UI, see my article on the topic.

The obvious approach: the model produces the A2UI markup directly. That's exactly what our first version looked like – and it needed 37 seconds and over 46,000 tokens for a single dashboard. The model had to produce an extensive structure token by token, issue tool calls for the data along the way, and occasionally got confused in the process, which forced correction loops. How we sped this up by a factor of 300 is described in detail in a separate article; here we're interested in the architectural decision behind it.

It goes like this: the model no longer produces markup, only a small, application-specific DSL – a compact description of which tiles the dashboard should show:

{
  "tiles": [
    { "type": "boardingPasses", "count": 2 },
    { "type": "bookedFlightsList", "showCheckInButton": true },
    { "type": "flightSearch", "defaultFrom": "Graz", "defaultTo": "Hamburg" },
    { "type": "rentalCars" },
    { "type": "hotels" },
    { "type": "weatherList" }
  ]
}

This DSL is defined as a Zod schema. The agent is limited to a single step: it translates the request into the DSL, nothing more. The model learns how the DSL is structured from examples in the system prompt.

The model passes the generated DSL to the renderDashboard tool. That tool validates the generated DSL against the Zod schema, and violations go back to the model as errors, which it can then use to correct its call.

The actual work is done by the route: it intercepts the call's arguments, deterministically compiles the DSL into the complete A2UI user interface, and derives from it which data needs to be loaded – without any further reasoning by the model. Because the DSL is compact, it also caches extremely well: for a dashboard that's already known, the model isn't called at all anymore.

Stale content isn't a concern here, because A2UI separates structure and data via data binding: only the structure of the user interface comes from the cache; the data bound to it is fetched fresh by the application on every call.

To the user, the result looks the same as before:

The generated dashboard with boarding passes, booked flights, flight search, rental cars, hotels, and weather

The original motivation for this rework was performance. The second gain, however, is at least as valuable: control. The model simply cannot express anything the DSL doesn't provide for – no unexpected layout, no invented components, no injected content. Every one of these cases would have to be caught by output checks when generating markup directly; here they don't exist as a possibility at all. The price is the flip side of the same coin: the solution cannot respond to wishes the DSL doesn't cover. For most business applications that's a good trade, because there predictability counts for more than unlimited dynamism.

Context That Never Goes on the Journey

The second decision concerns not what the model produces, but what it gets to see.

A2UI allows for so-called custom catalogs: the client announces its own UI components – names, schemas, descriptions – so the agent can use them in generated user interfaces. The boarding passes in the dashboard from the previous section are exactly such a case: behind them sits the TicketWidget, an Angular component of the client that the agent merely refers to by name.

In the demo, the catalog descriptions travel from the client to the server as a context entry and end up there as a section in the agent's system or developer prompt. That's convenient for local development, but it opens a channel: a manipulated client can slip prepared component descriptions to the agent – and a description text in the system or developer prompt is an ideal spot for a prompt injection.

The architectural answer to this: the client only transmits the catalog's id. In the demo, a flag when registering the catalog is enough for that:

// src/app/app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    [...]
    provideA2uiCatalog(customCatalog, { 
      sendCatalogDescription: false 
    }),
    [...]
  ],
};

The renderer still receives the full catalog – locally, the application renders exactly as before. However, the client now only passes the catalog id to the server. The server obtains the schemas and descriptions from a trusted registry it controls itself – in the simplest case a map that resolves catalog ids to stored catalog descriptions. This makes the attack channel disappear completely – not because a filter inspects the descriptions, but because they never leave the client. What isn't transmitted cannot be manipulated in transit. It's the same least-privilege principle known from tool provisioning, applied to context: accept as little as possible from the client, obtain as much as possible from sources the server trusts.

Tool Granularity as an Architectural Decision

The way tools are cut also determines how much room for error the model has in the first place – in both directions.

An example of deliberately fine granularity comes from the demo's co-planning: the user and the agent work on an action plan together, and for changes the client offers the model targeted frontend tools – swapPlanSteps swaps two steps, movePlanStep moves one, removePlanStep removes one. Each of these tools is a small, clearly delimited operation that addresses steps via their stable ids:

In response to "Please swap the two steps", the agent calls – highlighted in red – getPlan and swapPlanSteps, and the action card shows the plan with the order swapped

A tool that works with two stable ids leaves the model hardly any room for error. The alternative – the model re-emits the entire plan on every change – sounds simpler, but is the most error-prone variant of all: when re-emitting, steps can get lost, details can mutate, wording can drift. The fine-grained tools rule out this class of errors, because the plan itself never passes through the model; it lives in the application state and the model merely expresses change requests.

The same logic can be turned in the opposite direction. For complex actions with a fixed choreography – rebooking a flight, say: cancel plus rebook, in the right order, with rollback in case of failure – a deliberately coarse tool is the safer choice: a transaction in code instead of a choreography in the model.

The decision criterion is the same in both cases: degrees of freedom belong where judgment is required – and nowhere else. Which steps get swapped is decided by the model; how a rebooking runs correctly as a transaction is decided by code.

NOTE

Agentic UI with Angular

If you want to make architectural decisions like these not just here and there, but embed them systematically into larger applications:
In my book Agentic UI with Angular I cover exactly these patterns and trade-offs in detail.

Cover of the eBook Agentic UI with Angular

Learn more about the eBook →

Workflows: The Model Understands, the Code Works

The decisions shown so far constrain individual interactions. The next one concerns entire processes – and it's perhaps the most important architectural decision in agentic systems: splitting a process into agentic and deterministic steps.

The demo's Travel Planner assembles package tours – flights and hotels for a route like "Graz to Rome, with a dinner stop in Vienna". That flights are loaded first, then hotels, and that an overall plan emerges at the end, is fixed before the first request arrives. There's no reason to have the model reinvent this order on every call – and plenty of reasons not to: reproducibility, testability, cost.

The demo therefore deliberately divides the work:

  1. An agent with a cheap, weak model extracts the criteria from the free-form user request – a rough plan of flight legs and overnight cities. Pure language understanding, no decisions.
  2. Deterministic workflow steps use these criteria to load the data: flight candidates per leg, hotels per city. No model anywhere in sight.
  3. A finalizer agent with a stronger model makes the actual selection from the candidates – the only place where judgment counts.
  4. Code validates the selection before it reaches the user.

The Travel Planner agent extracts the rough plan and hands it to the workflow with the steps Find Flights, Find Hotels, and Finalize

The first two workflow steps are ordinary TypeScript code: a loop over the legs of the rough plan fetches the flight candidates for each leg – a direct service call without a detour through a model – and analogously the second step loads the hotel options for each overnight city.

Only the last step brings a model back on board. It weighs price against arrival time and hotel location against star rating; its answer is validated against a target schema. And even it isn't trusted blindly: afterwards, code deterministically re-checks the selection – every chosen flight must actually be among the candidates for its leg, every hotel among the options for its city; otherwise a fallback to the first candidate kicks in. Hallucinated flight numbers are thereby structurally ruled out: the model selects, but the code defines the set to select from. That's exactly the reason for the whole workflow detour – it creates the points at which code can control the model: before the model call through curated candidates, afterwards through a validated selection.

This structure becomes visible to the user, because the step boundaries travel to the client via AG-UI events, and the client turns them into a progress indicator:

The Travel Planner during generation: the steps Flights and Hotels are complete, the step Travel Plan is still running

This division of labor pays off three times over. Cost: two of three steps consume no tokens at all, and the criteria extraction runs on the mini model. Quality: the finalizer only sees curated candidates instead of half the database. And reliability: order, data retrieval, and validation are code – testable with perfectly ordinary unit tests.

Determinism Doesn't End at the Server

The decisions so far all live in the backend. The mindset behind them – trust the model only where there's no deterministic alternative – doesn't end at the API boundary, though. A look into the Angular client shows the same pattern one level higher up the stack.

The Travel Planner renders its travel plan from the model's tool calls and could simply rely on the model emitting the hotels in the right order alongside the flights. It doesn't: its store arranges the hotels deterministically along the route – and not just when the plan is first set, but on every change to the plan:

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

The helper function orderHotelsByRoute assigns each hotel to the first flight leg that arrives in its city. The order the user sees is therefore code, not model output:

The finished travel plan with a summary, the flights in travel order, and matching hotels for both cities

That's the same pattern as with the dashboard's DSL and with the downstream check in the workflow: the model supplies content, but the structure – order, assignment, consistency – is ensured by deterministic code. Anyone building Agentic UIs should treat their own client exactly like the server: reliability is a matter of code, in the frontend too.

The Sandbox: Let It Generate the Code, Not the Result

The last decision is the most far-reaching – and at first glance the most counterintuitive. The demo's reporting page answers natural-language questions about flight data with a chart:

The reporting page answers a natural-language question with a bar chart about delayed and on-time flights

The obvious route would be to put the flight data into the model's context and have it calculate the shares itself. Frontier models have gotten better at this than their reputation suggests – you still shouldn't rely on it. Reliability drops as the volume of data grows, arithmetic remains a structural weakness because of token-based processing, and the actual problem isn't the error rate but its invisibility: nobody can tell by looking at a miscalculated number that it's wrong. On top of that comes the economics – every row of data would have to travel through the context window and costs tokens and latency.

What language models are demonstrably strong at, by contrast, is writing code. So we turn the task around: the model doesn't calculate, it describes how the calculation is done – as a small JavaScript snippet. Deterministic code takes care of execution, the result is reproducible, and the snippet itself remains inspectable.

Running LLM-generated code on your own server does require an isolation layer, because the code is transitively derived from user input – whoever controls the prompt influences the code. The demo relies on quickjs-emscripten: the JavaScript engine QuickJS, compiled to WebAssembly. The LLM-generated code runs in a fully functional but empty JavaScript world: communication with the outside isn't provided for at all – no network access, no file system, no access to the surrounding server process and its configuration. The only doors are host functions that we explicitly hand in.

The server tool executeJavaScript takes the LLM-generated code, runs it in the QuickJS Emscripten sandbox, and arrives at the name/value pairs for the bar chart:

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

The helper function runSandbox wraps the low-level API of quickjs-emscripten and makes it more accessible.

This example shows how narrow those doors are: loadFlights delegates to the very API that the regular flight search uses as well. The generated code can therefore only load flight data, nothing else. submitResult, in turn, is the only return channel: the snippet delivers its result through exactly one call to this function.

The data flow is worth noting: the raw flight data exists exclusively inside the sandbox execution. The model never gets to see it – as the tool result it only receives the finished aggregate, a few dozen bytes instead of hundreds of rows of data. The sandbox is therefore not just a security boundary but a cost boundary as well. And via the Details button you can look up which code the model wrote – transparency a directly calculating model could never offer:

The detail view of the reporting page shows the JavaScript code generated by the model, with loadFlights calls, aggregation, and submitResult

If you want to push the idea of a minimal attack surface further, you don't even need to allow a general-purpose programming language: for aggregations, a single expression in a query language such as JSONata is often enough – no loops, no side effects, no JavaScript engine. The trade-off is the same as with the dashboard DSL: less expressive power in exchange for more control.

Summary

All the decisions shown here follow the same principle: they shrink the model's space of possibilities instead of checking its outputs. The DSL takes away the model's ability to produce unexpected markup. Minimal context transfer takes away a manipulated client's ability to inject instructions. Granular tools take away the model's ability to lose steps when rephrasing a plan – and coarse, transactional tools take away its ability to run a choreography incorrectly. The workflow puts order and candidate sets into code, so hallucinated flight numbers are structurally eliminated. The client arranges what it displays by itself. And the sandbox gives generated code an empty world with exactly two doors – the model describes how the calculation is done instead of doing it itself.

Architecture doesn't replace the remaining guardrails: input and output checks, human-in-the-loop patterns, approvals, and budgets keep their place. But it determines how much those checks still have to catch at all. Anyone who asks the architecture first – can the model even make this mistake? – needs less checking code afterwards, less threshold tuning, and less trust in the daily form of a non-deterministic system. The guiding principle behind it is simple: the model understands intent; structures, processes, and guarantees are delivered by code.

Interested in production-ready Agentic UI architectures?

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

Workshop: Agentic AI with Angular – AG-UI, A2UI, MCP Apps & HITL patterns

See all details →

FAQ

What does "Architecture as a Guardrail" mean?

Architectural decisions that structurally rule out an agent's unwanted behavior instead of detecting it after the fact: a DSL that can only express what's provided for; context that's never transmitted; tools whose granularity makes errors impossible; workflows that fix order and candidate sets in code; sandboxes that open only explicitly defined doors for generated code.

How do architectural guardrails differ from input and output checks?

Checks observe and intervene: they inspect messages or responses and block, filter, or rewrite them – deterministically or model-based. Architectural guardrails come earlier: they shape the system so the class of errors cannot occur in the first place. The two complement each other – the more the architecture rules out, the less remains for checks to do.

How does a DSL make an LLM's outputs more controllable?

The model only translates the user request into a compact description defined by a Zod schema; the actual structure – the A2UI markup of a dashboard, for instance – is generated from it by deterministic code. This means the model cannot express anything the DSL doesn't provide for. Side effects: substantially faster generation, fewer tokens, and cacheability. The price is reduced generative flexibility.

Why does the workflow combine a weak and a strong model?

Because the tasks differ in difficulty: extracting cities and dates from a sentence is something a cheap mini model handles reliably; weighing candidates against each other requires judgment and therefore a strong model. In between, deterministic steps load the data entirely without a model, and after the selection, code validates the result against the candidate lists – which rules out a hallucinated selection.

Why let the model write code instead of having it calculate the result directly?

Because language models are demonstrably strong at writing code but unreliable at arithmetic over larger volumes of data – and wrong numbers are invisibly wrong. Generated code, by contrast, runs deterministically, is inspectable, and is reproducible. In a sandbox such as QuickJS (isolated via WebAssembly, with time and memory limits and exclusively explicitly handed-in host functions), the attack surface stays minimal as well, and the raw data never has to pass through the context window.

Agentic UI with Angular

Architecting Agentic AI with Open Standards

Integrate AI Agents in Angular with Open Standards.

More About the Book