Skip to main content

How to Filter AG-UI Events Before They Reach the Browser

· 6 min read
Jagdish Kumawat
Founder @ Dewiride

An AG-UI server streams a lot more than a chat page shows. Microsoft Agent Framework's events carry the model provider's own update in rawEvent — response IDs and the exact model version — the final event reports token usage, and reasoning models send encrypted reasoning. A server route between the agent and the browser can pass on only what the page needs. This guide shows the filter and real events before and after it.

Why Filter​

Microsoft's Security Considerations for AG-UI recommends a trusted frontend server between browsers and an AG-UI server, and advises filtering sensitive data out of what reaches clients. A chat page draws text: it needs to know when a run and a message start, the words, and when they end — or that something failed. Everything else describes your back end, and whatever the browser doesn't need, it shouldn't get.

Prerequisites​

  • A server route that calls an AG-UI agent, for example with HttpAgent from @ag-ui/client. The examples come from the trusted Next.js route in Part 6 of the Microsoft Agent Framework series, with @ag-ui/client and @ag-ui/core 1.0.4.

Step 1: See What the Agent Sends​

Here is one event of a short answer, straight from a Microsoft Agent Framework 1.28 agent backed by a Foundry model — the event that carries one word:

One TEXT_MESSAGE_CONTENT event, straight from the agent
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg_016547aff2cbd2f6006af44fc63dc08194a517c40fbdc52a00","delta":"Which","rawEvent":{"authorName":"service-desk","role":"assistant","contents":[{"$type":"text","text":"Which","annotations":null,"additionalProperties":null}],"additionalProperties":null,"responseId":"resp_016547aff2cbd2f6006af44fc56fd8819497b8d90039f75b7c","messageId":"msg_016547aff2cbd2f6006af44fc63dc08194a517c40fbdc52a00","conversationId":"resp_016547aff2cbd2f6006af44fc56fd8819497b8d90039f75b7c","createdAt":"2026-11-11T11:21:41+00:00","finishReason":null,"modelId":"gpt-5.6-luna-2026-07-09","continuationToken":null}}

The page needs type, messageId and delta. rawEvent repeats the word and adds Foundry's response and conversation IDs and the exact model version. The same answer's stream also began with a REASONING_ENCRYPTED_VALUE event of 4,495 bytes, and ended with a RUN_FINISHED event that carried usage:

usage in RUN_FINISHED
"usage": [{"model": "gpt-5.6-luna-2026-07-09", "inputTokens": 254, "outputTokens": 59, "totalTokens": 313, "reasoningTokens": 40, "cachedInputTokens": 0}]

Step 2: Allow Only the Events the Page Uses​

In the server route, list the event types the browser may receive:

app/api/chat/route.ts
import { type BaseEvent, EventType } from "@ag-ui/core";

// All the browser receives. Every other event stays on this server.
const eventsForTheBrowser = new Set<string>([
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_START,
EventType.TEXT_MESSAGE_CONTENT,
EventType.TEXT_MESSAGE_END,
EventType.RUN_FINISHED,
EventType.RUN_ERROR,
]);

An allowlist names what may pass; everything else, including event types added in a future version, stays out. When a page later needs another kind of event — tool calls, say — you add it on purpose.

Step 3: Drop the Provider's Data from Each Event​

Where the route forwards events, skip the ones not on the list, and copy each remaining event without rawEvent and usage:

app/api/chat/route.ts (forwarding events)
subscription = agent.run(input).subscribe({
next(event) {
if (!eventsForTheBrowser.has(event.type)) {
return;
}
// rawEvent and usage carry the model provider's own data: Foundry's
// response IDs, the exact model version and the token counts.
const { rawEvent: _rawEvent, usage: _usage, ...forBrowser } = event;
send(forBrowser);
},

macOS-style code window showing the eventsForTheBrowser set of six AG-UI event types and the next handler that skips other types and removes rawEvent and usage before sending each event to the browser

const { rawEvent: _rawEvent, usage: _usage, ...forBrowser } = event is JavaScript's object rest syntax: it takes rawEvent and usage out under names that are never used, and puts every other field into forBrowser. The underscores tell the linter the names are unused on purpose. send writes the event to the browser's stream (How to Stream Server-Sent Events from a Next.js Route Handler).

Step 4: See What the Browser Gets​

The same kind of event, through the route:

One TEXT_MESSAGE_CONTENT event, through the route
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg_0d99f7f2df0789e5006af44fbd8fa88190bd50c1175cf51c06","delta":"Is"}
Straight from the agentThrough the route
One word's event638–648 bytes119–124 bytes
Encrypted reasoningOne event of 4,495 bytesNot sent
RUN_FINISHEDWith usage: model version and token countsWithout usage
Event typesRUN_STARTED, REASONING_ENCRYPTED_VALUE, TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, TEXT_MESSAGE_END, RUN_FINISHEDThe same, without REASONING_ENCRYPTED_VALUE

The page works exactly as before — it never used the dropped data — and each word now travels in about a fifth of the bytes.

Common Mistakes​

MistakeWhat happensDo this instead
A blocklist of events to dropNew event types reach the browser without anyone decidingAn allowlist of events to pass
Dropping RUN_ERRORThe page never learns that a run failedKeep it on the list
Sending event instead of forBrowserrawEvent and usage reach the browser anywaySend the copy

Troubleshooting​

SymptomLikely causeFix
A new feature's events don't arriveTheir type isn't on the listAdd it deliberately
rawEvent still appears in the browserThe original event is sent, not the copySend forBrowser, not event

FAQ​

Is the encrypted reasoning a secret?​

It's unreadable, but it is the provider's internal data and the page has no use for it. Dropping it also saved 4,495 of the 13,891 bytes of events the agent sent for this short answer — about a third.

Does filtering break multi-turn conversations?​

No. The conversation lives on the agent's side, identified by threadId; the browser needs none of the dropped fields to continue it.

Conclusion​

Keep an allowlist of the AG-UI event types your page draws, and copy each event without rawEvent and usage before you send it on. The browser gets the words and the run's status; the provider's IDs, model version, token counts and encrypted reasoning stay on your server.

Additional Resources​