What the Frontend Owns in an Agent Product
The model runs elsewhere. The client is a UI for an operation that streams, takes tools, fails halfway and must never be trusted with authority.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Where does the frontend stop and the agent begin, and which of these problems are actually mine?
Someone wants to ask a question in plain language and get a useful, trustworthy answer — including knowing what the system did on their behalf and being able to stop it.
Post the prompt to an endpoint, await the response, render the markdown. It is a form submission with a longer wait.
The wait is not longer, it is open-ended. A request that may take a minute cannot use the interaction pattern of one that takes a moment (Loading, Error, Empty — The States You Did Not Render).
- The wait is not longer, it is open-ended. A request that may take a minute cannot use the interaction pattern of one that takes a moment (Loading, Error, Empty — The States You Did Not Render).
- The answer arrives in pieces, so "await the response" throws away the only thing that makes the wait tolerable.
- The operation is not one request. It is a run that may call several tools, each of which can fail independently while the run continues.
- The output is untrusted content rendered into your DOM. Markdown that permits raw HTML is an injection sink (Cross-Site Scripting).
- A model proposing "delete these 40 records" is not the same as the user asking for it, and a UI that renders the two identically has already made the mistake (A Suggestion Is Not an Authorization).
What is actually happening
In the browser, not in the framework.
- The architecture is User → Frontend → Agent Backend → Model / Tools. The model is never reached from the browser directly: the key would be public, the cost uncontrollable, and the authorization non-existent (The Browser Is a Runtime).
- The agent backend owns the loop — prompt, model call, tool selection, tool execution, iteration. The frontend observes that loop and lets the user steer it.
- What crosses the boundary is a stream of events, not a response body: tokens, tool-start, tool-result, citation, error, done. Modelling it as a typed event stream rather than a growing string is what makes the UI tractable (Server-Sent Events).
- Every tool the agent calls is a backend operation that must be authorized as one, against the *user's* identity, not the model's intent (What the Frontend Is Responsible For in Auth).
- Cancellation is a two-sided operation: aborting the client request stops you rendering, and the server must be told so it stops working and stops paying for tokens (Cancelling a Request Nobody Is Waiting For).
What this makes the browser do
And which of it is avoidable.
- Parsing and appending stream chunks on the main thread, potentially hundreds of times per response — the single most expensive thing an agent UI does badly (Streaming a Response Without Melting the Device).
- Re-rendering a transcript that grows without bound, unless the transcript is windowed (List Virtualization).
- Markdown-to-DOM conversion per chunk, which is real CPU and is easy to run far more often than necessary.
- Holding the whole conversation in memory, including every tool result, unless something evicts it (Memory Leaks).
Where each concern actually lives
The clarifying move is to stop thinking of this as "an AI feature" and start thinking of it as a client for a long-running remote operation. Every one of its hard problems already has a home in this domain: streaming is a transport question, partial failure is an error-state question, cancellation is a request-lifecycle question, and rendering the output is a sanitization question.
What is genuinely new is only the last row of the table below — that the thing producing the output is not the user, and may have been influenced by text neither of you wrote.
| Concern | Which frontend problem it really is | Where the depth lives |
|---|---|---|
| Tokens arriving over time | Streaming transport and incremental rendering | [[server-sent-events]], [[ai-streaming-ui]] |
| A run that takes a minute | Loading, pending and terminal states | [[loading-and-error-states]] |
| Stopping it | Request cancellation, both sides | [[request-cancellation]] |
| A tool that failed mid-run | Partial failure and honest error surfacing | [[citations-and-partial-results]] |
| Rendering the answer | Untrusted content in a DOM sink | [[sanitization-and-trusted-html]] |
| Doing something on the user's behalf | Authorization, which the client never owns | [[ai-action-safety]] |
A run is a state machine, not a boolean
The most common structural mistake is tracking isLoading and a string. It cannot express "streaming, and the third tool failed, and the user has asked to stop but the server has not acknowledged yet" — which is a state real runs reach routinely.
Modelling the wire as a typed event union costs a few lines and makes every UI question answerable by looking at the state rather than by inferring it from what happens to be on screen.
- 1Submit
Sends the prompt and opens the stream.
fails by Allowing a second submit while the first run is live, producing two interleaved streams.
- 2Stream tokens
Appends text incrementally.
fails by Re-rendering the whole transcript per token; announcing every token to a screen reader.
- 3Run tools
Reports what the system is doing.
fails by Narrating imagined reasoning instead of observable state (Showing What the System Is Doing).
- 4Handle a tool failure
Surfaces it without ending the run.
fails by Swallowing it, so the answer is built on a gap nobody sees.
- 5Terminate
Reaches complete, cancelled or truncated.
fails by Treating a dropped connection as completion, presenting half an answer as the whole one.
- 6Offer a next step
Retry, edit, or continue.
fails by Retrying a run whose tools already had effects (Stopping It, and Trying Again Safely).
1type RunEvent =2 | { type: 'token'; text: string }3 | { type: 'tool_start'; id: string; label: string }4 | { type: 'tool_result'; id: string; ok: boolean; summary: string }5 | { type: 'citation'; id: string; url: string; quote: string }6 | { type: 'error'; recoverable: boolean; message: string }7 | { type: 'done'; reason: 'complete' | 'cancelled' | 'truncated' }8 9type RunState =10 | { status: 'idle' }11 | { status: 'streaming'; text: string; tools: ToolState[] }12 | { status: 'cancelling' } // asked to stop, not yet acknowledged13 | { status: 'ended'; reason: 'complete' | 'cancelled' | 'truncated'14 text: string; failedTools: ToolState[] }truncated is the state everyone forgets, and it is the one that matters most: it is the difference between an answer and the first half of one.
What is genuinely new here
Everything above is ordinary frontend engineering wearing a new hat. One thing is not, and it is worth isolating so it does not get lost among the mechanics.
In every other part of this domain, the content your UI renders came from your users or your systems, and the actions your UI offers were chosen by you. In an agent product, the content came from a model that read documents, and the actions are proposed by that model at runtime. The interface therefore has to carry a distinction that no other interface needs: this is what the system did, and that is what something suggested doing. Collapsing those two into one visual treatment is the characteristic failure of agent UIs, and it is a design failure before it is a security one.
<div class="message"> Deleted 40 archived records. </div>
<!-- proposal: not done, and clearly not done --> <section aria-labelledby="p1"> <h3 id="p1">Suggested action</h3> <p>Delete 40 archived records older than 2024.</p> <button>Review and confirm</button> </section> <!-- fact: server confirmed, and says so --> <section aria-labelledby="d1"> <h3 id="d1">Completed</h3> <p>Deleted 40 records. <a href="/audit/8f21">View audit entry</a></p> </section>
The first version asks the user to trust a sentence. The second distinguishes a proposal from a confirmed outcome, gives the confirmation a verifiable trace, and puts a human decision between the model and the effect — which is also exactly where the authorization check belongs.
How to build it
Most important first.
- Model the wire as typed events. A discriminated union of
token | tool_start | tool_result | citation | error | donemakes every UI state derivable instead of guessed at. - Give the run a state machine — idle, streaming, tool-running, cancelled, failed, complete — and render from it. Agent UIs get confusing precisely where someone tracked three booleans instead.
- Make cancellation a first-class control, always reachable, never hidden behind a hover (Stopping It, and Trying Again Safely).
- Treat every byte of model output as untrusted input to your renderer (Sanitization and Trusted HTML).
- Show what the system did, not what it might have been thinking (Showing What the System Is Doing).
- Put the authorization decision on the server and render only its confirmed result (A Suggestion Is Not an Authorization).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- This is one of the hardest accessibility surfaces in modern frontend work, because the interface is a continuously changing region of text — which is exactly what assistive technology handles worst (Live Regions and Announcement).
- Never stream tokens into a live region. Each token becomes an announcement, and the result is unusable — a screen-reader user hears a stuttering word-by-word crawl and cannot skim. Announce at meaningful boundaries, or on completion, and let the user read the transcript at their own pace.
- The run's state must be perceivable without seeing an animation. A spinning icon with no accessible name says nothing; "Searching documents" as text does.
- The stop control needs a real accessible name and must be reachable by keyboard at any point during the run — cancelling is the one thing a user most needs when something is going wrong (Keyboard Operability).
- When a response completes, decide deliberately where focus goes. Moving it to the answer interrupts someone mid-sentence; leaving it in the input is usually right, with the completion announced (Focus Management).
What can go wrong
- The stream ends mid-sentence and the UI shows a truncated answer as if it were complete. A run needs a terminal state that distinguishes finished from interrupted.
- A tool fails, the run continues, and the UI never mentions it — so the answer is built on a gap the user cannot see.
- The connection drops and the partial answer is silently discarded, losing work the user watched being produced.
- Retry re-runs a tool that already had an effect, doing it twice (Retries, and the Duplicate Order).
- The transcript grows until the tab is slow, because nothing bounds it.
- A cancel can land while a tool call is already in flight server-side; the tool completes and its effect exists even though the UI says cancelled.
- A retry can race the first attempt's side effect, producing the action twice.
- A second prompt submitted before the first run finishes produces two streams that will interleave unless one is cancelled or they are keyed separately (Out-of-Order Responses).
- Model output is untrusted content. It is not user input and it is not your content — it is text produced by a system that read documents you do not control (Cross-Site Scripting).
- The browser cannot hold a model API key. Anything shipped to the client is public, so the agent backend exists partly to be the thing that holds credentials at all.
- Indirect prompt injection means a document the agent retrieved can attempt to steer it. The UI must therefore never present model output as carrying the user's authority (A Suggestion Is Not an Authorization).
- Rendering a link the model produced means rendering an attacker-influenceable URL: validate the scheme, and never let
javascript:through (Sanitization and Trusted HTML).
- "The frontend is the agent." It is a client. The loop, the tools and the authority live on the server.
- "Streaming is just a nicer loading state." It changes the failure model: you can now fail after having shown the user something.
- "The model checked, so it is allowed." The model is not an authorization system and cannot be one (A Suggestion Is Not an Authorization).
- "Markdown is safe." Markdown renderers commonly permit raw HTML, and a permissive one turns model output into script (Sanitization and Trusted HTML).
Measuring it, and what changes in the field
- Time to first token separately from time to completion — they are different experiences and only the first one decides whether the wait feels broken.
- Cancellation rate and where in a run it happens: a spike after a particular tool usually means that tool is too slow to be inline.
- Tool failure rate per tool, surfaced in frontend telemetry, because the client sees failures a server-side view of "the run completed" does not (Network Failures Only the Client Can See).
- Main-thread time per streamed response — the metric that catches the re-render-per-token mistake before users do (Long Tasks).
- On a slow device, per-token rendering cost dominates and a naive implementation drops frames throughout the entire answer.
- On a flaky network, mid-stream disconnection stops being an edge case and becomes a routine path that needs a real design.
- On a long conversation, memory and re-render cost grow with history rather than with the current turn.
- Streaming makes the wait tolerable and makes everything else harder: partial state, partial failure, cancellation semantics and mid-stream errors all become your problem.
- A strict server-side authorization boundary means some interactions need a round trip that a client-side check would have avoided. That round trip is the product working correctly.
- Showing tool progress builds trust and exposes internal structure you may later want to change.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe client/agent-backend split and the untrusted-output rule hold regardless of which model or vendor is behind the backend, because they follow from where the credentials live and who can be authorized.
- SPEC-EVOLVINGVendor streaming formats, tool-calling schemas and event vocabularies are changing quickly and are not standardised across providers. Treat any specific wire shape here as illustrative; model your own event union and adapt at the boundary rather than letting a vendor format reach your components.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — a run that spans several tools with independent failure is a distributed operation, and partial failure is its native condition.