DEV Community

Cover image for Building Generative UIs in Angular with Dynamic Components
Maina Wycliffe
Maina Wycliffe

Posted on Edited on Originally published at mainawycliffe.dev AI-assisted

Building Generative UIs in Angular with Dynamic Components

Hello everyone, it’s been a while since I last wrote. It’s been difficult trying to find my position as a writer in the age of AI, but let me see if I still have it.

I have been building AI solutions for the better part of the last two years, and I’d like to spend more time sharing this, especially around areas that interest me. I am, and always will be, a full-stack engineer. Apart from a single role a few years back, I have always worked on almost every aspect of a system, end to end, and I have enjoyed this part of my job.

Those are hard habits to shake. I have found out that I enjoy building AI solutions and agents, wanting to have my fingers in all kinds of sauces. This has led to me working on and exploring very interesting projects such as skein-js, an open-source runtime for LangGraph and DeepAgents. I’m also working on a few others that I will share in due course.

What is wrong with text

Why am I opening with this? Because every skill and experience I have gathered over the last ten years has become more important when building agents. One of those is thinking about how people interact with them.

Most of the time, we interact with agents through text. Don’t get me wrong, text is versatile and expressive, and this makes it amazing for an agent because it gives the user maximum flexibility. Unlike UI elements, which try to fit users into boxes, text gives the agent the flexibility to handle almost any query, without us having to guess what the user cares about.

But here is the thing: this is a double-edged sword. With a text-only agent, I need to sit down and compose myself, sift through rows of text, and find the right language to communicate back. Markdown helps, but its vocabulary only takes us so far. I am not saying we should replace text with UI elements, but that we should find a balanced middle ground where what works best for each situation is available. Sometimes I need the freedom to explain what I want, and sometimes I just want to pick a flight from a list without having to type everything back to the agent.

What if agents could respond with UI elements people can interact with—forms, tables, flight cards—and respond to those interactions? That is the core of today’s post.

Watch the Angular agent-to-UI demo

A flight-booking flow driven by agent responses. The flight and booking data are simulated.

This is a two-part series. Today, we’ll handle this in Angular without a dedicated UI-rendering library; the backend still uses Genkit and Zod. In the next article, we’ll look at one of my favourite libraries for this, json-render.

I wrote a general introduction on my company website. Here, I’m demonstrating a custom agent-to-UI pattern, rather than implementing the formal A2UI protocol. You can find the full demo code on GitHub.

Defining a component catalogue

For those of you who have spent years and years building frontend applications, there are things we care about, and one of those is a consistent design language, alongside performance and accessibility. The last thing we want is to leave those to the whims of an agent.

Instead, we build every component ourselves, following our company’s best practices and design language, and let the agent choose when to render it and what inputs to pass in.

Let’s circle back to the flight comparison card in the video. We need to display the available flights and the recommended one, highlight those under the user’s budget, and let the user pick one.

Flight comparison card showing Nairobi to Mombasa flights, fares in KES, a recommended flight, and options highlighted below the budget.

The flight comparison card, captured from the demo before a flight is selected.

We would start by defining our component just like any other Angular component. I’ve stripped out the template and internal methods here so we can focus on the inputs:

import { Component, input } from '@angular/core';
import type { RenderInstruction } from '@shared/schema';

// Take the input types from the contract we share with the agent.
type Props = Extract<
  RenderInstruction,
  { component: 'FlightComparisonCard' }
>['props'];

@Component({
  selector: 'app-flight-comparison-card',
  template: '', // Flight card template and styling omitted.
})
export class FlightComparisonCard {
  // The agent supplies these values in its render instruction.
  origin = input.required<Props['origin']>();
  destination = input.required<Props['destination']>();
  date = input.required<Props['date']>();
  flights = input.required<Props['flights']>();

  // Optional: highlight fares below the user's budget.
  highlightUnder = input<Props['highlightUnder']>(undefined);

  // Selection handler and other internal methods omitted for now.
}
Enter fullscreen mode Exit fullscreen mode

As you can see, this is still a standard Angular component. The internal workings are beside the point of today’s post; the agent needs to know that we have a component named FlightComparisonCard, and these are the inputs it accepts.

Defining the contract

Once we have our components, we need to let the agent know what it can and can’t use. We do this by defining the catalogue and its inputs with Zod. Here is the flight card’s props schema, with some descriptions left out:

const FlightComparisonProps = z.object({
  origin: z.string().describe('Departure city, e.g. "Nairobi".'),
  destination: z.string().describe('Arrival city, e.g. "Mombasa".'),
  date: z.string().describe('Travel date in YYYY-MM-DD format.'),
  highlightUnder: z.number().optional(),
  flights: z.array(Flight).min(2).max(5),
});
Enter fullscreen mode Exit fullscreen mode

Flight is the schema for one flight in our list, and we also add descriptions so the model knows what these fields mean and how to use them. The descriptions help the model, but they don’t do the validation themselves. For example, .min(2).max(5) checks that we get between two and five flights.

In our case, we are using a discriminated union, with component telling us which props belong to each entry in the catalogue. I’ve shown two entries below:

export const RenderInstruction = z.discriminatedUnion('component', [
  z.object({
    component: z.literal('FlightComparisonCard'),
    message: z.string(),
    props: FlightComparisonProps,
  }),
  z.object({
    component: z.literal('SeatMapCard'),
    message: z.string(),
    props: SeatMapProps,
  }),
  // Other catalogue entries and descriptions omitted.
]);
Enter fullscreen mode Exit fullscreen mode

You can learn more about discriminated unions in my earlier post.

Once we have this in place, we can pass the schema when invoking the model. Our AgentResponse schema allows either a text reply or a component instruction, as you can see in the model call:

const { output } = await ai.generate({
  system: SYSTEM,
  messages: formattedMessages,
  output: { schema: AgentResponse },
});
Enter fullscreen mode Exit fullscreen mode

Genkit uses this schema to guide the model, then parses and validates whatever comes back, so a successful call gives us typed data. This checks the structure of the response; we still need to check that the information itself is correct.

Rendering the component

Usually, we know which Angular component to place in a template when we build the application. Here, we only know after the agent responds, so we use Angular’s dynamic component rendering. The agent returns a name, but Angular needs the component class, so we connect the two with a registry:

export const COMPONENT_REGISTRY = {
  FlightComparisonCard,
  SeatMapCard,
  // Other registered components omitted.
};
Enter fullscreen mode Exit fullscreen mode

Our host looks up that name through componentType() and uses NgComponentOutlet to render the class. The inputs binding passes the agent’s props into the inputs we defined earlier:

<ng-container
  *ngComponentOutlet="componentType(inst.component); inputs: inst.props"
/>
Enter fullscreen mode Exit fullscreen mode

So we are still rendering the Angular components we built ourselves, with the agent deciding which one to use and what values to pass in.

Handling actions

But what happens when the user selects a flight? We don’t want them to go back to the chat and type out a choice they have already made, so we need to send that interaction back to the agent.

For this, we create an AgentBus service. Observables are a perfect fit here because we have a stream of user interactions that another part of the application needs to listen to. I’m using an RxJS Subject, which lets us both emit events and subscribe to them:

import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';

// Only the flight-selection variant is shown here.
type AgentEvent = {
  type: 'FLIGHT_SELECTED';
  flightId: string;
  airline: string;
  price: number;
};

@Injectable({ providedIn: 'root' })
export class AgentBus {
  // Components emit into this stream; the host subscribes to it.
  readonly events = new Subject<AgentEvent>();

  emit(event: AgentEvent) {
    this.events.next(event);
  }
}
Enter fullscreen mode Exit fullscreen mode

Calling emit() pushes an event into the stream through .next(). Because this is a shared Angular service, our dynamically rendered components and the host can use the same bus. We then inject it into the flight card and emit FLIGHT_SELECTED when the user picks a flight:

// Inside FlightComparisonCard; also import inject and signal.
private bus = inject(AgentBus);
selecting = signal(false);

select(f: Props['flights'][number]) {
  // Skip repeated clicks while the selection is being processed.
  if (this.selecting()) return;
  this.selecting.set(true);

  this.bus.emit({
    type: 'FLIGHT_SELECTED',
    flightId: f.id,
    airline: f.airline,
    price: f.price,
  });
}
Enter fullscreen mode Exit fullscreen mode

The host subscribes to this stream and forwards each interaction to sendEvent(). That method adds the action to the conversation as a user message, then calls the agent again:

// Inside AiUiHost; other fields and methods omitted.
private bus = inject(AgentBus);

ngOnInit() {
  this.bus.events.subscribe((event) => this.sendEvent(event));
}
Enter fullscreen mode Exit fullscreen mode
// Inside the host's sendEvent() method:
const summary = `event: ${event.type} ${JSON.stringify(event)}`;
this.messages.update((m) => [...m, { role: 'user', content: summary }]);
await this.callAgent();
Enter fullscreen mode Exit fullscreen mode

In our backend prompt, we tell the agent to return SeatMapCard after FLIGHT_SELECTED. The host renders that next instruction, and we go through the same process for the seat, passenger details, and payment.

The full loop is click → AgentBus → conversation message → agent response → next card. From the agent’s point of view, the user has replied; they have just done it through the component instead of typing in the chat.

Bringing it all together

So, when I ask our agent for a flight, the Angular app sends the conversation to POST /agent, and Genkit uses our schema to guide and validate the response. The agent chooses from the components we have already built and returns a name, a message, and the props. We use that name to look up the component in our registry, and NgComponentOutlet renders it with those inputs.

When I select a flight, our AgentBus carries that action back to the host, which adds it to the conversation as another user message. The agent can then respond with the seat map, and we go through the same process for the rest of the booking. This is where our frontend experience comes in: we still own the components and how they behave, while the agent decides which one fits the next turn.

For me, that is the middle ground I’m interested in: keeping text available when I need to explain myself, and bringing in a UI when it makes the task easier. In the next article, we will look at how to take this to the next level with json-render. You can find the full demo on GitHub if you want to explore the rest of the components.

Top comments (0)