Skip to content

Level 3 · Internals · 5 min

How do you render UI an agent invented without trusting it?

An agent that returns interface instead of text is describing a tree your renderer will build. Everything upstream of that model — retrieved documents, tool output, other users' text — is now upstream of your DOM.

For a couple of years an agent's output was text, and the interesting failure was that the text might be wrong. That is a content problem. You read it, you check it, you move on.

A newer class of agent returns interface. Not prose describing a form — a specification of one: these components, this layout, bound to this data, calling these actions. A2UI and AG-UI are two attempts to standardise the wire format, and the shape is the same in both. The model emits a tree. Your renderer builds it.

The moment that happens, the model is no longer producing content for a person to read. It is producing instructions for your application to execute.

The thing that makes this different from user input

Everyone already knows not to trust what a user types. The reflex is decades old: escape it, parameterise it, never concatenate it into anything that gets interpreted.

Agent output feels different because it comes from your model, over your connection, in a format you designed. It has none of the smell of user input. It is also, in almost every real deployment, derived from user input — plus retrieved documents, plus tool results, plus whatever was in the last twenty turns of conversation.

That is the whole problem. Prompt injection has been discussed for years as a way to make a model say something it should not. When the model's output is rendered as interface, the same injection stops being a content problem and becomes an execution one. A poisoned document in your vector store is now a document that can ask for a component to be rendered.

You do not have to believe a model is malicious to take this seriously. You only have to believe that something in its context might be, and that a model is very good at doing what its context suggests.

What the renderer is actually agreeing to

It is worth being concrete about what a naive renderer hands over.

If the tree names a component and you look that name up in a map of everything you export, the agent chooses which of your components exist on the page. If any of them are administrative, it can ask for one.

If the tree carries arbitrary properties and you spread them onto a component, the agent sets your props. In most frameworks a prop beginning on is an event handler, and a prop called href will happily accept a string beginning javascript:.

If the tree contains markdown and you render it as markup, the agent writes HTML.

If the tree patches a data model by path — JSON Pointer, or anything shaped like it — and you walk that path with bracket access, the agent writes to __proto__. Prototype pollution is not exotic here; it is the natural consequence of letting a string decide which key gets assigned.

And if the tree is recursive, and a node's child is itself, your renderer is a denial of service you shipped yourself.

None of these require a clever attack. They are what happens when a tree is treated as data rather than as a request.

Allowlist, not blocklist

The defence is boring, which is the point. The renderer decides; the agent only asks.

A catalog is an allowlist of components. The agent names a component and the renderer looks it up in a registry it was given at construction. A name that is not in the registry does not render — it is not an error to log and continue past, it is a node that produces nothing. The set of things that can appear on the page is fixed before the agent says anything.

Handlers and functions are stripped unconditionally. Not "if they look suspicious" — every key matching on*, and every value that is a function, on every node, always. Conditional stripping is a rule with an exception, and the exception is the bug. Doing it unconditionally means you never have to reason about whether a particular property was safe in a particular position.

Pointer writes refuse the dangerous keys. __proto__, constructor and prototype are rejected at the point the path is walked, before any assignment. This is one comparison and it is the difference between a data model patch and arbitrary object graph modification.

Markdown is escaped and then marked up, in that order. Escape the text so that any HTML it contains becomes literal, then apply your markdown transformations to the escaped string. The reverse order — render markdown, then try to sanitise the result — is how you end up maintaining a list of tags you forgot about.

Recursion is bounded. Both the render tree and any expression evaluator get a depth limit. A cyclic tree is a plausible model mistake before it is an attack, and the fix is the same either way.

Actions declare where they can be called from. An agent asking your application to invoke something is reasonable. An agent choosing which of your functions are invocable is not. The callable surface is declared by you, and a request outside it is refused rather than dispatched.

Why the prompt is not the place for any of this

There is an obvious cheaper option: tell the model not to do these things.

It is worth being clear about why that is not a defence. An instruction in a system prompt is a request to a probabilistic system, competing with everything else in the context window — including, in the case that matters, text specifically written to override it. It reduces the frequency of the failure. It does not change what is possible.

The renderer's allowlist changes what is possible. That is the entire distinction, and it is the same one that separates a prepared statement from asking nicely for no semicolons.

The prompt is still worth writing. It is just not the boundary.

Where the boundary belongs

One useful test for any of this: if the model were replaced tomorrow by an adversary with the same output channel, what would change?

If the answer is "nothing, because the renderer only ever builds components I shipped, with properties I allow, at a depth I bound" — the boundary is in the right place. If the answer requires the model to be well behaved, the boundary is a hope.

I built a Svelte renderer for A2UI with these constraints in it, which is where the specifics above come from: svelte-a2ui. The protocol layer is a pure reducer with no framework imports, so the security properties are testable without rendering anything — which turned out to matter more than any single rule in the list.

The rules themselves are not novel. They are the ordinary discipline of untrusted input, applied to a source that does not feel untrusted yet.

Practical

You can name what an agent-generated interface is allowed to do in your app, and point at the code that enforces each limit rather than the prompt that requests it.

Take an agent in your stack that returns anything structured. Write down every field the renderer reads. For each one, answer what happens if it contains `__proto__`, a string beginning `javascript:`, a component name you have never shipped, or a node whose child is itself. Fix the ones with no answer.

  • Security
  • UX
  • Open source