Mert Özen Further With Every Line
Artificial Intelligence AI Interfaces with Angular — #05

Tool Calls, Approval Flows and Generative UI: Letting the Assistant Choose Components

Mert Özen Nov 14, 2026 15 min 2 views
Tool Calls, Approval Flows and Generative UI: Letting the Assistant Choose Components

Showing the assistant's tool calls, tying a sensitive action to a time-limited single-use approval card and letting the model pick an allowed component instead of writing text is done in Angular with typed SSE events and a validating component registry. In this post we show a small model claiming it displayed cards that never appeared, an error text arriving at the UI as props and client validation stopping it, and we look at Hashbrown and its postinstall telemetry.

Version note: The code in this post was built and run with Angular 22.2 (TypeScript 6.0, Node.js 24) and .NET 10; the backend uses Microsoft.Extensions.AI 11.0.0 and OllamaSharp 5.5.2, browser runs used Playwright 1.65 with headless Chromium 158, and the local model is Ollama 0.34.4 (qwen3:4b). Hashbrown 0.8.3 was only inspected, not installed.

Guess First

Two requests reach the Bookstore assistant (Bookstore, the online bookstore used as the shared example across this site's series). The first is "Recommend crime novels under 20 TL." The assistant has a tool that shows books as cards with an "Add to cart" button, and the system prompt says "do not list the books in text, show the cards". Does a small model follow that instruction? The second request is "Please cancel my order 1042." Cancelling an order is a tool that requires approval. What happens if the user ignores the approval card for two minutes, and what does the model say if the user rejects it?

In the previous posts the assistant only talked. In this post we reflect tool calls in the UI, add an approval card for sensitive actions and build generative UI, where the model picks "which component, with which data" instead of writing text. We answer the questions with the real model; some of the answers forced me to change the design.

Reflecting Tool Events in the UI

The tool event from post 1 reports the start and end of every tool call with its callId and name. The UI's job is to turn the technical name into a sentence the user understands:

// Status lines shown to the user while a tool runs
const TOOL_LABELS: Record<string, string> = {
  show_books: 'Looking through the catalog…',
  cancel_order: 'Cancelling the order…',
};
@for (tool of message.tools; track tool.callId) {
  @if (tool.phase === 'started') {
    <p class="status">{{ toolLabel(tool.name) }}</p>
  }
}
@for (block of message.blocks; track $index) {
  <app-generative-block [block]="block" />
}
@if (message.approval; as approval) {
  <app-approval-card [request]="approval" [disabled]="store.busy()" (decide)="store.respond(message.id, $event)" />
}
<app-message-body [text]="message.text" [final]="message.ending !== null" [sources]="message.sources" />

For an unknown tool name it shows "Working on it…"; when a new tool is added on the server the UI does not break, it just shows a more generic sentence. Showing the tool name, its arguments or its result raw is tempting, but it tells the user nothing and puts internal details (table names, internal IDs) on screen. On the store side there is one change to the state machine from post 3: the first-token timer is now stopped not only by thinking or delta but by any sign of life from the model (tool, approval, ui).

The Approval Flow: Server Side

Cancelling an order cannot be undone; the model should not do it on its own. Here we move the flow we built with ApprovalRequiredAIFunction in the tool calling post of the AI with .NET series, together with the design decisions from the human-in-the-loop post of the AI Agents and MCP series, onto streaming. Instead of running a tool call that requires approval, FunctionInvokingChatClient puts a ToolApprovalRequestContent into the stream and ends the turn; ToSseAsync turns it into an approval event:

case ToolApprovalRequestContent approval when approval.ToolCall is FunctionCallContent call:
    yield return new SseItem<object>(new ApprovalEvent(approval.RequestId, call.Name, call.Arguments,
        (int)PendingApprovals.Lifetime.TotalSeconds), "approval");
    break;

The server keeps the messages so far (the user's message, the model's tool call) for two minutes. An approval is single-use and time-limited:

// Turns waiting for approval; an expired approval is not accepted. In memory for the demo.
public sealed class PendingApprovals(TimeProvider time)
{
    public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(2);
    private readonly ConcurrentDictionary<string, PendingApproval> _items = new();

    public void Save(ToolApprovalRequestContent request, Guid conversationId, List<ChatMessage> history,
        ChatMessage userMessage, List<ChatMessage> messages) =>
        _items[request.RequestId] = new(request, conversationId, history, userMessage, messages, time.GetUtcNow() + Lifetime);

    // An approval can be used only once and only before it expires
    public PendingApproval? Take(string requestId) =>
        _items.TryRemove(requestId, out PendingApproval? item) && item.ExpiresAt > time.GetUtcNow() ? item : null;
}
// The decision from the approval card: hands the pending turn back to the model with the approval response and continues the stream
app.MapPost("/chat/approvals/{approvalId}", IResult (string approvalId, ApprovalDecision decision, IChatClient chat,
    AssistantTools tools, PendingApprovals pending, ILogger<Program> logger, CancellationToken ct) =>
{
    PendingApproval? item = pending.Take(approvalId);
    if (item is null)
    {
        return TypedResults.Problem(title: "Approval not found", statusCode: 404,
            detail: "This approval request has expired or was already answered.");
    }

    item.Messages.Add(new ChatMessage(ChatRole.User, [item.Request.CreateResponse(decision.Approved, decision.Reason)]));
    IAsyncEnumerable<SseItem<object>> events =
        StreamTurn(item.ConversationId, item.History, item.UserMessage, item.Messages, chat, tools, pending, logger, ct);
    return TypedResults.ServerSentEvents(events.WithHeartbeatAsync(TimeSpan.FromSeconds(5), ct));
}).RequireRateLimiting("chat");

The two steps with curl, and using the same approval a second time:

$ POST /chat/sse {"message":"Please cancel my order 1042."}
   243 ms  event: thinking
  4830 ms  event: approval
  4830 ms  data: {"approvalId":"ficc_955a064b","tool":"cancel_order","arguments":{"orderId":1042},"expiresInSeconds":120}
  4874 ms  event: usage
  4874 ms  event: done

$ POST /chat/approvals/ficc_955a064b {"approved":true}
     6 ms  event: tool
     6 ms  data: {"callId":"955a064b","name":"cancel_order","phase":"started"}
     8 ms  event: tool
     8 ms  data: {"callId":"955a064b","name":"cancel_order","phase":"completed"}
  4085 ms  event: done
TEXT: 'Your order 1042 has been successfully cancelled.'

$ POST /chat/approvals/ficc_955a064b {"approved":true}
HTTP/1.1 404 Not Found
{"title":"Approval not found","status":404,"detail":"This approval request has expired or was already answered.",...}

# server log
cancel_order(1042): cancelled

The first stream has no tool started event for cancel_order: FunctionInvokingChatClient replaces the call that requires approval with an approval request in the stream. The tool ran at the start of the second stream, after the approval, and took 2 milliseconds. Guessing an approval id to approve on someone else's behalf, or reusing an old approval, ends in a 404. In production Take should also check that the approval comes from the user who started the request; the PendingApprovals in the AI with .NET series did that.

The Approval Card

The card shows the request in plain language, with two buttons and a countdown:

// A human-readable summary for sensitive tools; an unknown tool is shown with its name and arguments as they are
function describe(tool: string, args: Record<string, unknown> | null): string {
  if (tool === 'cancel_order') {
    return `Cancel order ${args?.['orderId']}?`;
  }
  return `Run ${tool} with ${JSON.stringify(args)}?`;
}

@Component({
  selector: 'app-approval-card',
  template: `
    <div class="card">
      <p>{{ question() }}</p>
      @if (request().state !== 'pending') {
        <p class="muted">{{ request().state === 'approved' ? 'Approved.' : 'Rejected.' }}</p>
      } @else if (secondsLeft() === 0) {
        <p class="muted">This request expired. Ask the assistant again.</p>
      } @else {
        <button type="button" (click)="decide.emit(true)" [disabled]="disabled()">Approve</button>
        <button type="button" (click)="decide.emit(false)" [disabled]="disabled()">Reject</button>
        <span class="muted">{{ secondsLeft() }} s left</span>
      }
    </div>
  `,
  styles: `.card { padding: 8px 12px; border: 1px solid #f59e0b; border-radius: 8px; background: #fffbeb; }
    .muted { color: #6b7280; font-size: 0.875rem; }`,
})
export class ApprovalCard {
  readonly request = input.required<ApprovalRequest>();
  readonly disabled = input(false);
  readonly decide = output<boolean>();
  protected readonly question = computed(() => describe(this.request().tool, this.request().arguments));

  private readonly now = signal(Date.now());
  protected readonly secondsLeft = computed(() =>
    Math.max(0, Math.ceil((this.request().expiresAt - this.now()) / 1000)),
  );

  constructor() {
    // Update "now" every second for the countdown
    const timer = setInterval(() => this.now.set(Date.now()), 1000);
    inject(DestroyRef).onDestroy(() => clearInterval(timer));
  }
}

The buttons are disabled while a stream runs ([disabled]="store.busy()"): the server stores the pending turn right before the done event, so the user cannot approve before done arrives and run into a 404. In the store the decision continues the same assistant message; no new user message is created:

// The decision from the approval card: the server continues the same turn and the answer is appended to the same assistant message
async respond(messageId: number, approved: boolean): Promise<void> {
  const approval = this.messages().find((m) => m.id === messageId)?.approval;
  if (!approval || approval.state !== 'pending' || !this.canSend() || !this.to('submitted')) {
    return;
  }
  this.patch(messageId, { approval: { ...approval, state: approved ? 'approved' : 'rejected' }, ending: null });
  await this.consume(messageId, (signal) => this.api.approve(approval.approvalId, approved, signal));
}

I ran three scenarios in Playwright against the real model:

$ node run05.mjs approve
card  : Cancel order 1042? | Approve | Reject | 120 s left
   7019 ms Sending… | Cancel order 1042? | Approved.
  12836 ms Cancel order 1042? | Approved.
bubble: Cancel order 1042? | Approved. | Your order 1042 has been successfully cancelled.

$ node run05.mjs reject
   6535 ms Sending… | Cancel order 1042? | Rejected.
  22380 ms Cancel order 1042? | Rejected.
bubble: Cancel order 1042? | Rejected. | Order cancellation requires your approval. Please confirm to proceed.

$ node run05.mjs expire
card  : Cancel order 1043? | Approve | Reject | 120 s left
later : Cancel order 1043? | This request expired. Ask the assistant again.

Approval and expiry behave as expected. The result of rejecting is worth a note, though: the order was not cancelled, but the model replied "your approval is required, please confirm", as if it took the rejection for a missing approval. The client was not sending a reason; a reason such as CreateResponse(false, "The customer decided to keep the order.") tells the model clearly what happened. That is also the UI's job: a short reason field next to "Reject", or at least a fixed, meaningful reason.

Generative UI: The Model Picks a Component

When a book recommendation arrives as text, the user has to copy the title and search for it. With generative UI the model picks a component instead of writing text and says which data to show it with; the UI renders that choice with its own trusted components. The model does not produce the component's code or behavior; it only picks from an allowed list.

I exposed the choice to the model as a "UI tool". The tool's result goes to the model as a normal tool result, and to the UI as a ui event:

// UI tools: their result is shown as a component, not as text
public static readonly IReadOnlyDictionary<string, string> UiComponents =
    new Dictionary<string, string> { ["show_books"] = "book-list" };

[Description("Shows books from the catalog to the customer as cards with an 'Add to cart' button. " +
    "Use this to recommend books instead of listing them in text.")]
public object ShowBooks(
    [Description("Optional genre: science-fiction, fantasy, classic or crime.")] string? genre = null,
    [Description("Optional maximum price in TRY.")] decimal? maxPriceTry = null,
    [Description("Optional short heading for the cards.")] string heading = "Recommended books")
{
    // The model only picks filters; book, author and price always come from the catalog
    if (genre is not null && !Catalog.Books.Any(b => b.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase)))
    {
        return new { error = $"Unknown genre '{genre}'. Use science-fiction, fantasy, classic or crime." };
    }
    List<BookCard> books = [.. Catalog.Books
        .Where(b => genre is null || b.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase))
        .Where(b => maxPriceTry is null || b.PriceTry <= maxPriceTry)
        .Take(6)
        .Select(b => new BookCard(b.Isbn, b.Title, b.Author, b.PriceTry))];
    logger.LogInformation("show_books(genre={Genre}, maxPriceTry={Max}) -> {Count} cards", genre, maxPriceTry, books.Count);
    return new BookListProps(heading.Length > 80 ? heading[..80] : heading, books);
}

I arrived at this design on the third attempt. The first design had two tools: search_books searched the catalog, and show_books(heading, isbns) showed the ISBNs the model picked as cards. qwen3:4b called search_books, did not call show_books, and wrote this to the user:

TEXT: 'Here are 3 crime novels under 20 TL available for viewing.'
# server log
search_books(genre=crime, maxPriceTry=20) -> 3

There were no cards on screen, but the model talked as if it had shown them. A two-step tool chain is too much for a small model; I reduced it to a single UI tool. On the second attempt the model called show_books but left out the required heading parameter; FunctionInvokingChatClient caught the exception and replaced the tool result with an error text. And my ToSseAsync sent that result to the UI without checking it:

 11363 ms  event: ui
 11363 ms  data: {"callId":"c3e342ae","component":"book-list","props":"Error: Function failed."}
# server log
show_books invocation failed.
System.ArgumentException: The arguments dictionary is missing a value for the required parameter 'heading'.

I made two fixes: heading became a parameter with a default value, and the server now turns only a tool result that finished without an error into a ui event (result.Exception is null). This is a live example of the first item in Common Pitfalls: data going into a component must be validated no matter where it comes from. On the third attempt the model called show_books(genre: "crime", maxPriceTry: 20) and the cards arrived. The design itself is a security decision too: the model does not write the title, author or price, it only picks filters. Even if the model said "show Dune for 1 TL", the card would show the catalog price.

The Component Registry and Validation

Even though the server is trusted, the client does not pass a ui event to a component as it is. Every component has a props schema, and a component that is not on the list is not rendered:

// Validates a ui event from the server; a block that does not match the schema is dropped
export function parseUiBlock(component: string, props: unknown): UiBlock | null {
  if (component !== 'book-list' || typeof props !== 'object' || props === null) {
    return null;
  }
  const p = props as Record<string, unknown>;
  if (!isText(p['heading'], 120) || !Array.isArray(p['books']) || p['books'].length > 12) {
    return null;
  }
  const books = p['books'].map(parseBook);
  return books.every((b) => b !== null) ? { component: 'book-list', heading: p['heading'], books } : null;
}
// Component registry: every component the model may pick is listed here explicitly
@Component({
  selector: 'app-generative-block',
  imports: [BookList],
  template: `
    @switch (block().component) {
      @case ('book-list') {
        <app-book-list [heading]="block().heading" [books]="block().books" />
      }
    }
  `,
})
export class GenerativeBlock {
  readonly block = input.required<UiBlock>();
}

parseBook requires a 13-digit ISBN, a non-empty title and author of at most 200 characters, and a price of zero or more for every book. To test the registry I sent three bad blocks through a fake stream: the error text from the second attempt, an unlisted admin-panel, and a book with a negative price:

$ node run05.mjs bad
[console.warning] Rejected UI block "book-list" Error: Function failed.
[console.warning] Rejected UI block "admin-panel" {userId: 1}
[console.warning] Rejected UI block "book-list" {heading: Deals, books: Array(1)}
bubble: Done. | book lists: 0

All three were rejected, no card appeared on screen and the text was shown normally. There is deliberately no @default in the @switch: showing even an "unknown content" box for an unrecognized component would mean letting the model put something on screen.

Add to Cart Always Comes from the User

The "Add to cart" button on the cards calls Bookstore's regular cart service. The model has no tool that adds to the cart; adding only happens with the user's click:

// Only the user's click adds to the cart; the model never calls this directly
add(book: BookCardData): void {
  this.items.update((list) => [...list, book]);
}

With the real model:

$ node run05.mjs books
    166 ms Sending…
    389 ms Thinking…
  12713 ms Thinking… | Looking through the catalog…
  12722 ms Thinking… | Recommended books
  45441 ms Recommended books
bubble: Recommended books | And Then There Were None | Agatha Christie | ₺16.90 | Add to cart | Murder on the Orient Express | ...
        | The Hound of the Baskervilles | Arthur Conan Doyle | ₺12.90 | Add to cart
        | Here are crime novels under 20 TRY: Agatha Christie’s And Then There Were None (16.90 TRY), Murder on the Orient Express ...
header before: Cart: 0
header after : Cart: 1

# server log
show_books(genre=crime, maxPriceTry=20) -> 3 cards

The cards arrived at 12.7 seconds; the model thought for another 33 seconds in its second turn and wrote a sentence. The answer to the first question in Guess First is "half": the model showed the cards but ignored the "do not list the books in text" instruction and listed the same three books in the text too. It is harmless for the UI but repetitive; a larger model, or a tool result saying "the cards are shown, do not repeat the list", reduces it. Prices are shown with the ₺ sign via currency: 'TRY' : 'symbol-narrow'; with symbol the first attempt showed TRY16.90.

A Ready-Made Library: Hashbrown, and When Not to Use Any of This

Writing our own registry is instructive, but it is not the only option. Hashbrown is a generative UI library for Angular and React; its Angular package @hashbrownai/angular is at version 0.8.3 on the day of writing, so pre-1.0, and it requires @angular/core ^22.0.0 as a peer dependency. I looked at the package's type definitions: you expose a component to the model with its description and props schema through exposeComponent, and uiChatResource is a resource that provides the messages, the stream and the components the model picked as signals. The provider call still goes through a backend; the documentation also has a getting-started guide for ASP.NET Core. This is the counterpart of the registry we wrote by hand: an allowed component list, props with a schema, and resolving partial JSON while streaming.

I did not install the package, and there is a reason. When I downloaded it with npm pack and looked inside, I found a postinstall script in package.json. On install the script creates a ~/.hashbrown/installation-id file, reads user.name and user.email from the global and local git config and the repository owner from the origin remote, and tries to send them to hashbrown.dev as install telemetry. The environment variable DO_NOT_TRACK=1 or HASHBROWN_TELEMETRY_DISABLED=1 turns it off. This does not mean bad intent, but in a company project a dependency sending a developer's e-mail address out is a decision that needs approval. Managing lifecycle scripts is the subject of the npm supply chain post in the Frontend Security series.

Not using generative UI at all is often the right answer as well. If the user knows what they want and the request fits a form ("crime, under 20 TL"), a filter panel is faster, cheaper and gives the same result every time: the cards in this post took 12 seconds and two model turns, while a filter query comes back within milliseconds. The value of generative UI is where the request arrives as free text and it is not known in advance which component is needed.

Production Notes

Tie the approval to the user. Even if the approval id cannot be guessed, it should not be the only defense; the approval endpoint should compare the user in the session with the user who started the request.

Spell out the consequences on the approval screen. "Cancel order 1042?" is short but not enough; refund, shipping and whether it can be undone belong on the card. That text should be written by the code that owns the tool, not by the model.

Pick the approval lifetime according to the risk of the action. Two minutes is reasonable for cancelling an order; for a payment it may be shorter, for a report longer. When it expires the server must reject the request too; hiding it in the UI is not enough.

Measure UI tool results. How often does the model show cards, how often does it list books in text, how often do users add to the cart from the cards? These numbers tell you whether generative UI actually pays off.

Common Pitfalls

First, passing data produced by the model or a tool to a component input without validating it. In the measurement an error text made it all the way to the UI as the props of a book list; without the schema check on the client there would have been a broken component on screen.

Second, taking the price, stock or product name from props written by the model. The model should only choose; the data should always come from the system's own source.

Third, giving the model actions such as adding to the cart, paying or ordering directly as tools. The user's click is both proof of intent and the simplest defense against prompt injection.

Fourth, leaving the approval card enabled while a stream runs. An approval that arrives before the server has stored the pending turn ends in a 404, and the user does not understand what happened.

Fifth, leaving a multi-step tool chain to a small model. The model can stop halfway through the chain and describe the result as if it existed; in the measurement it said "available for viewing" while there were no cards.

A Small Experiment

★☆☆ Add a reason option next to the "Reject" button ("Keep the order", "Wrong order") and pass the reason to CreateResponse. Does the model's reply after a rejection change?

★★☆ Add a second component to the registry: an order-status card that shows an order's status, and a show_order UI tool that produces it. Extend parseUiBlock with a discriminated union that tells the two components apart.

★★★ Install Hashbrown in a separate project with DO_NOT_TRACK=1 or with --ignore-scripts, and expose the same book-list component to the model with exposeComponent. Compare it with our own registry: which validation does the library do, and which do you still have to do yourself?

The assistant now calls tools, asks for approval and picks components. We did all of it with our own small SSE contract; for every new capability we added a new event to both the server and the client. In the next post we try doing the same work with standard protocols: the event families of AG-UI and AI SDK UI, their packages on the Angular side, and what they gain and cost compared with our own contract.