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

Connecting Through Standard Protocols: Choosing Between AG-UI, AI SDK UI and Our Own SSE Contract

Mert Özen Nov 14, 2026 16 min 2 views
Connecting Through Standard Protocols: Choosing Between AG-UI, AI SDK UI and Our Own SSE Contract

Choosing between your own SSE contract, AG-UI and AI SDK UI comes down to the backend, the number of clients and the bundle and wire cost you accept. In this post we connect the same assistant to Angular over all three, and measure AG-UI's defaults sending the model's reasoning to the browser in a 368 KB stream, the two protocol libraries adding 500-680 kB to the page, and an abort arriving as RUN_ERROR.

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, OllamaSharp 5.5.2 and AGUI.Server 1.0.1, the Angular side uses @ag-ui/client 1.0.4 and @ai-sdk/angular 3.0.173 (ai 7.0.173), browser runs used Playwright 1.65 with headless Chromium 158, and the local model is Ollama 0.34.4 (qwen3:4b). Agent Framework's AG-UI hosting package (Microsoft.Agents.AI.Hosting.AGUI.AspNetCore, preview) was not run; it is described from the official documentation.

Guess First

For five posts the Bookstore assistant (Bookstore, the online bookstore used as the shared example across this site's series) has talked over our own SSE contract: delta, tool, ui, approval and the rest. Imagine moving the same assistant to a standard protocol. You wire up AG-UI's official .NET SDK and its official TypeScript client, both with their default settings. The model is going to write a one-sentence book recommendation. How many bytes does that one sentence take on the wire: 3 KB, 30 KB or 300 KB? And on the Angular side, how many times larger than the chat screen itself is the JavaScript loaded for the protocol?

Both answers surprised me, and the first one turned up a security problem. In this post we move the chat from post 2 to AG-UI, then to AI SDK UI, measure the three contracts with the same question, and finally decide which one Bookstore should use.

Three Contracts, One Assistant

We will have three endpoints that use the same model, the same system prompt and the same show_books tool:

  • /chat/sse: this series' own contract. The event names are ours (delta, tool, ui, approval, ping…), and the conversation history is kept on the server under a ConversationId.
  • /agui: AG-UI. The request is a RunAgentInput (thread, run, message list, state, client tools), the response an SSE stream of JSON events that carry a type field.
  • /chat/ui-stream: AI SDK UI's UI message stream protocol. The request is a list of UI messages with parts, the response again JSON parts with a type field, ending with data: [DONE].

On the Angular side each one gets its own page: our own store (/), /protocols/agui using HttpAgent, and /protocols/ai-sdk using the Chat class. All three render through the app-message-body component from post 4, so through the same sanitizing pipeline; changing the protocol does not change the safe rendering rules.

AG-UI: Event Families and the Server Side

The AG-UI documentation groups events into families. Lifecycle: RUN_STARTED, RUN_FINISHED, RUN_ERROR and the optional STEP_*. Text: TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, TEXT_MESSAGE_END. Tools: TOOL_CALL_START, TOOL_CALL_ARGS, TOOL_CALL_END, TOOL_CALL_RESULT. State: STATE_SNAPSHOT, STATE_DELTA (RFC 6902 JSON Patch) and MESSAGES_SNAPSHOT. Reasoning (REASONING_*), activity and CUSTOM events come on top. The rule is simple: every run starts with RUN_STARTED and ends with RUN_FINISHED or RUN_ERROR; text and tool streams follow start, content, end. Situations that wait for an answer from outside, such as an approval, are not a separate event: the run ends with a RUN_FINISHED whose outcome.type is "interrupt", and the client sends the answer in the resume field of a new run.

There are two ways to do this in .NET. On September 25, 2026 Microsoft published the AG-UI .NET SDK together with CopilotKit: packages such as AGUI.Abstractions, AGUI.Server and AGUI.Client. Agent Framework's hosting package now sits on top of this SDK, and the same announcement renamed its APIs: MapAGUIServer instead of MapAGUI, AddAGUIServer instead of AddAGUI. The AI with .NET series post "Hosting Agents in ASP.NET Core: AddAIAgent, OpenAI-Compatible Endpoints, AG-UI and A2A" exposed an AIAgent with MapAGUIServer; that package is still in preview. In this post we skip the agent and expose the IChatClient we already have directly with AGUI.Server 1.0.1:

// JSON contract for the AG-UI types (RunAgentInput, BaseEvent and its subtypes)
builder.Services.ConfigureHttpJsonOptions(o =>
    o.SerializerOptions.TypeInfoResolverChain.Insert(0, AGUIJsonUtilities.DefaultTypeInfoResolver));
// The same assistant over AG-UI: the request is a RunAgentInput, the response an SSE stream of AG-UI events
public static void MapAgUi(this WebApplication app, string systemPrompt)
{
    app.MapPost("/agui", IResult (RunAgentInput input, IChatClient chat, AssistantTools tools,
        IOptions<JsonOptions> json, ILogger<AgUiRun> logger, CancellationToken ct) =>
    {
        ChatRequestContext context = input.ToChatRequestContext(json.Value.SerializerOptions);
        // The client sends the history; the system prompt and tools still live only on the server
        List<ChatMessage> messages = [new(ChatRole.System, systemPrompt), .. context.Messages];
        ChatOptions options = tools.CreateOptions();
        options.Tools = [.. options.Tools!.Where(t => t.Name == "show_books")];

        IAsyncEnumerable<BaseEvent> events = chat.GetStreamingResponseAsync(messages, options, ct)
            .AsAGUIEventStreamAsync(context, ct);
        return TypedResults.ServerSentEvents(events);
    }).RequireRateLimiting("chat");
}

ToChatRequestContext turns the AG-UI messages into a list of ChatMessage, and AsAGUIEventStreamAsync turns the ChatResponseUpdate stream into AG-UI events. The SDK handles RUN_STARTED, RUN_FINISHED and closing an open text block. I deliberately left the approval-gated cancel_order out of this endpoint; the AG-UI equivalent of the approval flow is the interrupt mechanism above, and it was not run in this post.

First Measurement: A 368 KB Sentence

I called the endpoint with curl, using the question from post 1:

$ curl -sN -X POST localhost:19640/agui -H 'Content-Type: application/json' \
    -d '{"threadId":"t1","runId":"r1","state":{},"messages":[{"id":"m1","role":"user",
         "content":"Recommend one classic novel for a long train trip, in one sentence."}],
         "tools":[],"context":[],"forwardedProps":{}}'
  1996 ms  data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1","protocolVersion":"1.0"}
  2009 ms  data: {"type":"REASONING_START","messageId":"msg_z14BF73Ie9fdBnuIrpY9kAtJ"}
  2009 ms  data: {"type":"REASONING_MESSAGE_START","messageId":"msg_z14BF73Ie9fdBnuIrpY9kAtJ","role":"reasoning"}
  2010 ms  data: {"type":"REASONING_MESSAGE_CONTENT","messageId":"msg_z14BF73Ie9fdBnuIrpY9kAtJ","delta":"Okay"}
  2010 ms  data: {"type":"REASONING_MESSAGE_CONTENT","messageId":"msg_z14BF73Ie9fdBnuIrpY9kAtJ","delta":","}
  2027 ms  data: {"type":"REASONING_MESSAGE_CONTENT","messageId":"msg_z14BF73Ie9fdBnuIrpY9kAtJ","delta":" let"}
# ...
 91505 ms  data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"1c3c26b2273943f482330bcbd95cbce4","delta":"For",
           "rawEvent":{"authorName":null,"role":"assistant","contents":[{"$type":"text","text":"For", ...},
           "responseId":"1c3c26b2...","createdAt":"2026-11-14T03:30:58.744719+00:00","modelId":"qwen3:4b",...}}
# ...
 92185 ms  data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1","outcome":{"type":"success"},
           "usage":[{"model":"qwen3:4b","inputTokens":543,"outputTokens":3484,"totalTokens":4027}]}

# event counts (body 368,272 bytes)
REASONING_MESSAGE_CONTENT  3456 events  354591 bytes
TEXT_MESSAGE_CONTENT         25 events   12499 bytes
other                         8 events    1182 bytes
TEXT: 'For a long train trip, *Emma* by Jane Austen is a classic comedy of manners perfect for a light read.'

The answer is a single 101-character sentence, the body is 368 KB. The difference has two sources. First, the SDK forwards the model's reasoning text (TextReasoningContent) to the browser as REASONING_MESSAGE_CONTENT events: 3456 events, 15,604 characters. Second, every text event carries the provider's raw ChatResponseUpdate in a rawEvent field, so a 3-character delta grows to 500 bytes.

More important than the size is what the reasoning text says. Joined together, it contained "the instructions say to call show_books instead of listing books in text" and a line-by-line repeat of the catalog notes. While thinking, the model restates the system prompt in its own words. In post 1 we set up a backend to keep the system prompt and the tool definitions away from the browser; the default conversion carried them right back into the user's developer tools. Our contract only reported thinking with an empty thinking event.

The fix is to filter the events bound for the browser on the server:

// Filters the events that go to the browser and logs how the run ended
private static async IAsyncEnumerable<BaseEvent> ForBrowser(IAsyncEnumerable<BaseEvent> events, ILogger logger,
    [EnumeratorCancellation] CancellationToken ct)
{
    int count = 0;
    try
    {
        await foreach (BaseEvent e in events.WithCancellation(ct))
        {
            // The model's reasoning text never reaches the browser; REASONING_START/END is enough to show "thinking"
            if (e is ReasoningMessageStartEvent or ReasoningMessageContentEvent or ReasoningMessageEndEvent)
            {
                continue;
            }
            // rawEvent: the provider's raw update; meant for debugging, it multiplies the size of every delta
            e.RawEvent = null;
            count++;
            yield return e;
        }
    }
    finally
    {
        logger.LogInformation("AG-UI run closed: events={Count}, aborted={Aborted}", count, ct.IsCancellationRequested);
    }
}

The endpoint now returns TypedResults.ServerSentEvents(ForBrowser(events, logger, ct)). The same question again:

   272 ms  data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1","protocolVersion":"1.0"}
   284 ms  data: {"type":"REASONING_START","messageId":"msg_4074XgnKZnGl4EKKQcrfO4Sc"}
 96321 ms  data: {"type":"REASONING_END","messageId":"msg_4074XgnKZnGl4EKKQcrfO4Sc"}
 96322 ms  data: {"type":"TEXT_MESSAGE_START","messageId":"c5c215d9deda46028f3a5897d9eab7f0","role":"assistant"}
 96323 ms  data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"c5c215d9deda46028f3a5897d9eab7f0","delta":"For"}
# ...
 97055 ms  data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1","outcome":{"type":"success"},"usage":[...]}

# body 3,535 bytes, 35 events
info: Bookstore.Assistant.Api.AgUiRun[0]
      AG-UI run closed: events=35, aborted=False

Down from 368 KB to 3.5 KB. But a new problem became visible: between REASONING_START and REASONING_END not a single byte arrives for 96 seconds. Our own endpoint sent a ping every 5 seconds during that time, and the 15-second silence timer from post 3 relied on it. AG-UI has no heartbeat event; by cutting the reasoning text we also cut what kept the stream alive. Agent Framework's Python hosting makes keepalive comments configurable; with AGUI.Server you have to add one yourself, either as an SSE comment line or as a CUSTOM event the client ignores. If a proxy or load balancer with a 60 or 100 second idle timeout sits in between, this stream gets cut halfway.

The cost per event differs too. The same kind of text chunk averages 37 bytes in our contract (event: delta plus {"text":"For"}) and 101 bytes in AG-UI; every TEXT_MESSAGE_CONTENT repeats the 32-character messageId. For one sentence the difference does not matter; for a long answer or a system with many users it shows up in the bandwidth bill.

Consuming It in Angular with HttpAgent

The HttpAgent class from @ag-ui/client manages the message list, the event order and the request itself; all we do is write from subscriber callbacks into signals:

// The chat from post 2 with AG-UI's HttpAgent: the library keeps the message list and the event order
export class AgUiChat {
  private readonly agent = new HttpAgent({ url: '/agui' });
  protected readonly messages = signal<readonly Message[]>([]);
  protected readonly status = signal<RunStatus>('idle');
  protected readonly running = signal(false);
  protected readonly error = signal<string | null>(null);

  constructor() {
    // AG-UI events arrive through subscriber callbacks; writing them to signals is enough
    const subscription = this.agent.subscribe({
      onMessagesChanged: ({ messages }) => this.messages.set(messages),
      onReasoningStartEvent: () => this.status.set('thinking'),
      onTextMessageStartEvent: () => this.status.set('streaming'),
      onRunErrorEvent: ({ event }) => {
        // The client itself turns abortRun into a RUN_ERROR with code: 'abort'; stopping is not an error (post 3)
        if (event.code === 'abort') {
          this.status.set('idle');
          return;
        }
        this.status.set('error');
        this.error.set(event.message);
      },
    });
    inject(DestroyRef).onDestroy(() => subscription.unsubscribe());
  }

  protected async submit(event: Event, input: HTMLInputElement): Promise<void> {
    event.preventDefault();
    const text = input.value.trim();
    if (text === '' || this.running()) {
      return;
    }
    input.value = '';
    this.error.set(null);
    this.agent.addMessage({ id: crypto.randomUUID(), role: 'user', content: text });
    this.running.set(true);
    try {
      await this.agent.runAgent();
      this.status.set('idle');
    } catch (err) {
      this.status.set('error');
      this.error.set(err instanceof Error ? err.message : String(err));
    } finally {
      this.running.set(false);
    }
  }

  protected stop(): void {
    this.agent.abortRun();
  }
}

The code === 'abort' check in onRunErrorEvent was not there in the first version. When Playwright clicked the Stop button after 3 seconds, the screen showed this:

$ node run06.mjs stop
[requestfailed] /agui net::ERR_ABORTED
    155 ms Stop
    175 ms Thinking… | Stop
   3168 ms signal is aborted without reason | Send

runAgent() does not reject; the client turns the cancellation into a RUN_ERROR event of its own: {"type":"RUN_ERROR","message":"signal is aborted without reason","code":"abort"}. The rule from post 3 (an abort is not an error) still holds, only the place where you tell them apart changed. After the fix the user just gets the Send button back, and the server log shows that the cancellation reached the model: AG-UI run closed: events=2, aborted=True.

I forced a model failure mid-stream with the X-Chaos: fail-after-5 header:

$ node run06.mjs chaos
    188 ms Stop
    310 ms Thinking… | Stop
   6625 ms Stop
   6736 ms An error occurred while streaming the agent response. | Send
bubbles: [ 'Which is longer, Dune or The Hobbit? Answer in one sentence.', 'Dune is longer than' ]

# the last event on the wire
data: {"type":"RUN_ERROR","message":"An error occurred while streaming the agent response.","code":"StreamingError"}

The partial answer stays on screen and the error text is generic. On the server there is a surprise: the SDK catches the exception and turns it into RUN_ERROR, but writes nothing to the log. Our ToSseAsync logged "Model stream failed after 5 deltas" together with the exception in the same situation. If you want that information from the AG-UI endpoint, you have to wrap the update stream yourself before AsAGUIEventStreamAsync, or add the SDK's AGUIServerInstrumentation activity source to your tracing.

There are two more design differences. First, in AG-UI the client carries the history. The body HttpAgent sent with the second message:

$ node run06.mjs twice
2nd request messages: [
  'user: Which is longer, Dune or The Hobbit? Ans',
  'assistant: Dune is longer than The Hobbit.',
  'user: And which one is cheaper?'
]

In our contract the server kept the history itself under a ConversationId (the AI with .NET series post "Persisting Chat History: Stateless APIs, ConversationId, EF Core and Chat Reducers"). In AG-UI the "assistant" message the client sends is input the user can edit by hand; if you need to trust the model history, the server should keep its own record under the threadId. Agent Framework's hosting does this by persisting sessions by threadId.

Second, AG-UI has no equivalent of our ui event. I derived the generative UI from post 5 from the TOOL_CALL_RESULT of show_books; the result goes through the same parseUiBlock validation and the same @switch registry:

// AG-UI has no separate "ui" event: show_books' TOOL_CALL_RESULT goes through the same registry and becomes a component
protected readonly blocks = computed(() => {
  const names = new Map(
    this.messages().flatMap((m) => (m.role === 'assistant' ? (m.toolCalls ?? []) : [])).map((c) => [c.id, c.function.name]),
  );
  const blocks = new Map<string, UiBlock>();
  for (const m of this.messages()) {
    if (m.role === 'tool' && names.get(m.toolCallId) === 'show_books') {
      const block = parseUiBlock('book-list', parseJson(m.content));
      if (block) {
        blocks.set(m.toolCallId, block);
      }
    }
  }
  return blocks;
});

For "Recommend crime novels under 20 TL." the three cards arrived at 12.6 seconds; the model then thought for another 32 seconds in the second round and wrote one sentence. The whole tool result (TOOL_CALL_RESULT.content) goes to the browser, so if a tool's result has a field meant only for the model, you have to filter that too; in our contract only the results of the tools listed in UiComponents left the server.

AI SDK UI: Producing the UI Message Stream from ASP.NET Core

AI SDK's Angular package, @ai-sdk/angular, provides a Chat class. The official getting-started guide suggests a Node.js (Express) server that uses streamText as the backend; there is no ready-made server package for .NET. The protocol itself is open: each part is a JSON object with a type field on a data: line, the x-vercel-ai-ui-message-stream: v1 header is required on the response, and the stream ends with data: [DONE]. I produced it myself with a minimal API:

// The same assistant in the AI SDK UI message stream (v1) format
public static void MapUiMessageStream(this WebApplication app, string systemPrompt)
{
    app.MapPost("/chat/ui-stream", IResult (UiChatRequest request, HttpContext http, IChatClient chat,
        ILogger<UiChatRequest> logger, CancellationToken ct) =>
    {
        List<ChatMessage> messages = [new(ChatRole.System, systemPrompt)];
        foreach (UiMessage m in request.Messages.Where(m => m.Role is "user" or "assistant"))
        {
            string text = string.Concat(m.Parts
                .Where(p => (string?)p["type"] == "text")
                .Select(p => (string?)p["text"]));
            messages.Add(new ChatMessage(m.Role == "user" ? ChatRole.User : ChatRole.Assistant, text));
        }

        // Without this header the client does not read the stream as a UI message stream
        http.Response.Headers["x-vercel-ai-ui-message-stream"] = "v1";
        return TypedResults.ServerSentEvents(ToParts(chat.GetStreamingResponseAsync(messages, cancellationToken: ct), logger, ct));
    }).RequireRateLimiting("chat");
}

ToParts emits the start, start-step, text-start, text-delta, text-end, finish-step and finish parts in order, and sends {"type":"error","errorText":"..."} when the model fails; the same skeleton as ToSseAsync from post 3. The curl output:

    70 ms  x-vercel-ai-ui-message-stream: v1
    70 ms  data: {"type":"start","messageId":"26326d63811541cd8670883d256e28ca"}
    70 ms  data: {"type":"start-step"}
  3264 ms  data: {"type":"text-start","id":"f321a51cc8534a89862718048292fdda"}
  3264 ms  data: {"type":"text-delta","id":"f321a51cc8534a89862718048292fdda","delta":"D"}
  3289 ms  data: {"type":"text-delta","id":"f321a51cc8534a89862718048292fdda","delta":"une"}
# ...
  3621 ms  data: {"type":"finish-step"}
  3621 ms  data: {"type":"finish"}
  3621 ms  data: [DONE]

The Angular side is short:

// The same chat with AI SDK UI's Chat class: messages, status and error are signals inside
export class AiSdkChat {
  protected readonly chat = new Chat({
    transport: new DefaultChatTransport({ api: '/chat/ui-stream' }),
  });

  // A message is a list of "parts"; here we only join the text parts
  protected textOf(message: UIMessage): string {
    return message.parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
  }
}
@for (message of chat.messages; track message.id) {
  <article class="bubble" [class.user]="message.role === 'user'">
    @if (message.role === 'assistant') {
      <app-message-body [text]="textOf(message)" [final]="chat.status === 'ready'" />
    } @else {
      <p class="text">{{ textOf(message) }}</p>
    }
  </article>
}

The template says chat.messages, not chat.messages(). I looked at the package source: Chat keeps its state in a class called AngularChatState, and the messages, status and error getters read the signals inside it. Because the getter reads a signal, the template updates in a zoneless OnPush component too. The status values are submitted, streaming, ready and error; there is no done versus aborted distinction as in our state machine from post 3. After Stop an empty assistant bubble remains and status goes straight back to ready, so if you want to know that the user stopped, you have to track it yourself. With the error forced through X-Chaos, the partial answer stayed and the banner showed the server's errorText. AI SDK keeps the history on the client as well; in the second request the previous assistant message came back together with a [step-start] part.

The cost of this endpoint: about 90 lines for text alone. For tool calls you have to map the tool-input-start, tool-input-available and tool-output-available parts, for approvals tool-approval-request and tool-approval-response, and for reasoning the reasoning-* parts. The protocol changes at AI SDK's own release pace, and keeping this mapping current would be your job.

Bundle and Wire Cost

All three pages are lazy loaded, so the initial bundle (main, 262 kB) barely changed. The lazy chunks of the production build:

$ npx ng build
Lazy chunk files    | Names         |  Raw size | Estimated transfer size
chunk-CTghVaZi.js   | -             | 409.76 kB |                73.98 kB
chunk-Tp78nDQq.js   | agui-chat     | 266.24 kB |                52.50 kB
chunk-Czhftraw.js   | -             | 119.54 kB |                36.92 kB
chunk-BkuuAqW6.js   | ai-sdk-chat   |  85.32 kB |                20.63 kB
chunk-DJ2MUyp0.js   | assistant     |  14.03 kB |                 4.55 kB

▲ [WARNING] Module '@vercel/oidc' used by 'node_modules/@ai-sdk/gateway/dist/index.js' is not ESM

When I summed the --stats-json output package by package, the whole unnamed 409 kB chunk turned out to be zod; both protocol libraries define their message and event schemas with zod, and both pages load this chunk. The 119 kB chunk is marked, DOMPurify and highlight.js from post 4, a cost shared by all three pages. The largest part of the AG-UI chunk is @ag-ui/proto at 111 kB: the protobuf transport we do not use ends up in the bundle too. The warning comes from the ai package also importing the gateway provider we do not use.

ContractPage-specific JS (raw / transfer)Bytes per deltaHistoryHeartbeat
Our own SSE contract18 kB / 6.6 kB~37On the server (ConversationId)Yes (ping)
AG-UI (@ag-ui/client 1.0.4)679 kB / 128 kB~101Sent by the clientNo, you add it
AI SDK UI (@ai-sdk/angular 3.0.173)495 kB / 95 kB~83Sent by the clientNo, you add it

The delta size in the AI SDK row includes the id field of the text-delta part. Thanks to lazy loading this cost only hits users who open the assistant page, but 128 kB of transfer means more than a second on a slow mobile connection.

Which One to Choose?

My decision for Bookstore: the assistant itself stays on our own contract, and the two protocol pages stay in the repo as examples. The reasons are in the measurements: our contract already gives the smallest bundle, history kept on the server, a heartbeat and the approval flow; its only downside is that we add every new capability as an event on both sides ourselves. Situations that would change this decision:

  • AG-UI if the backend will be an Agent Framework agent or workflow, or if you expose the same agent to web, mobile and other clients at once. Shared state (STATE_SNAPSHOT/STATE_DELTA), approvals through interrupts and ready-made UI libraries such as CopilotKit are supported directly, and there is an official SDK from Microsoft on the .NET side.
  • AI SDK UI if the backend is Node.js and already calls the model through AI SDK. With streamText the protocol comes down to one line on the server; with a .NET backend like ours you produce it by hand.
  • Your own contract if the team is small, there is one client and the backend is .NET. Document the event vocabulary and keep it in one place, as in post 1 (chat-events.ts and ChatContracts.cs).

Choosing a protocol is not hard to reverse: on all three pages the model, the tools, the system prompt and the safe rendering stayed the same; only the wire format and the client layer that turns events into state changed. Keep that layer thin and swapping it later is a day's work.

Production Notes

Pin the protocol packages. On the day of writing @ai-sdk/angular was at 3.0.173 and its last npm update was the night before; @ag-ui/client was at 1.0.4. With ^, a minor update can change an event schema or a default behavior. Use an exact version and the lock file in package.json, and update deliberately through a pull request.

Measure the defaults. The AG-UI conversion forwarding the reasoning text and the raw provider updates is not something I read in the documentation; it is something I saw on the wire. The first thing to do after adding a protocol library is to look at what a real answer looks like on the wire.

Do not leave server logging to the library. The RUN_ERROR reached the client, but the exception was written nowhere. Log errors, cancellations and event counts in your own layer that wraps the stream.

Do not trust the history the client sends. In AG-UI and AI SDK UI the client sends the whole conversation with every request; an attacker can add any message to the history with the "assistant" role. Keep the history on the server, or at least sign it; the LLM Application Security series covers the prompt injection side.

Common Pitfalls

First, handing the protocol SDK's output to the browser without filtering it. In the measurement the default settings carried the reasoning text, and with it a summary of the system prompt, to the browser in a 368 KB stream.

Second, cutting the reasoning text and forgetting the heartbeat. 96 silent seconds mean a dropped connection for any proxy with an idle timeout in between.

Third, showing an abort as an error. HttpAgent turns the cancellation into a RUN_ERROR with code: 'abort'; if you do not check it, the user reads "signal is aborted without reason".

Fourth, adding a protocol library without looking at the bundle cost. An 18 kB chat page grew to 679 kB with two import lines.

Fifth, mistaking the sample backend in a protocol's documentation for a production architecture. The Express server in the AI SDK guide is a starting point; producing the same protocol from a .NET backend is code you will maintain.

A Small Experiment

★☆☆ Add a heartbeat to the /agui endpoint: if the source is silent for 5 seconds, send a CUSTOM event named ping. Receive it in HttpAgent's onCustomEvent callback and bring the silence timer from post 3 to the AG-UI page.

★★☆ Add the show_books tool to /chat/ui-stream: emit the tool-input-available and tool-output-available parts, and on the Angular side pass the tool-show_books part through parseUiBlock and show it with app-generative-block.

★★★ Move the cancel_order approval flow to AG-UI: on the server turn ToolApprovalRequestContent into an interrupt with AGUIStreamOptions.MapInterrupt, on the client show the approval card from pendingInterrupts and send the decision with runAgent({ resume: [...] }). Where do the expiry (expiresAt) and the single-use approval rule end up?

Whatever the wire format, what the user sees on screen is the same: waiting, streaming text, cards and errors. In the next post we make that screen usable for everyone: perceived waiting, announcing the answer to screen readers without reading every token, focus and keyboard, usage information, an "AI generated" label and thumbs up/down feedback.