Designing for an agent you can’t fully predict
An agent takes seconds to answer, calls tools on your behalf, can be wrong, and in this case deals with money. The Returns assistant is a chat interface for my Relay Returns agent, designed around those four facts.
- 9 / 9
- of Relay’s original test cases pass after the port
- 5
- visible steps for an eligible refund
- 2 vs 25
- screen-reader updates per reply, shipped vs naive
- 0
- refunds the agent can issue on its own
01The problem
Three moments where chat interfaces break
While it waits, a spinner says nothing: is it working, stuck, or about to time out? When it answers, the user has to decide whether to trust it, with nothing to go on but confident prose. When it fails, the usual red toast disappears along with the context.
On top of that, a returns agent touches money. Relay’s own policy, RET-01, already says eligible requests need human approval, so the interface has to make “approved by the rules” and “money has moved” impossible to confuse.
02Principles
What I held myself to
- Show the work, then get out of the way.
- The agent never moves money. A person does.
- Failure is information, not an apology.
- Calm for assistive technology. Streaming is a visual effect, not something to read aloud.
- Honest about what it is. It runs rules, not a model, and the page says so.
03Decisions
What I chose, and what it cost
Steps appear live, then fold away
- Chose
- Each tool call appears as it runs, in plain language (“Looking up order RX-104”, then what it found). When the answer arrives, the steps fold into one line, “Checked · 5 steps”, that can be reopened.
- Instead of
- A spinner, or steps that stay expanded after the answer.
- Because
- A spinner gives no evidence of progress and nothing to judge the answer by. Steps that stay open push the answer down and turn every reply into a log. Folding keeps the evidence one click away without making everyone read it.
- Trade-off
- Reopening costs a click, and on a very fast run the steps flash past before anyone can read them.
My headphones arrived damaged. Can I get a refund for RX-104?
Press Play, then switch modes and replay to compare.
A person approves the money
- Chose
- An eligible refund becomes a draft card marked “Awaiting human approval”, with “No money has moved” spelled out underneath.
- Instead of
- Issuing the refund as soon as the rules pass.
- Because
- Rules can be right and the outcome still wrong: fraud, a customer with history, a mistake in the order data. The policy already requires approval; the interface just has to make that state unmistakable.
- Trade-off
- Customers wait for a person, so the card has to answer “is it done?” at a glance. That is why the state has its own indicator and plain words, not just a status colour.
Failure is a state you can act on
- Chose
- When the order service is down, the reply says what couldn’t be checked, the steps show which tool failed, and “Try again” reruns the request in place, replacing the failed reply.
- Instead of
- A toast that disappears, or silently retrying in the background.
- Because
- A toast loses the context. Silent retries hide a flaky service and make waits unpredictable. A visible failure tells the user what happened and gives them the next step.
- Trade-off
- In this demo the outage is permanent, so retrying fails again. That is honest about the service, but it means the retry button can’t show a recovery here.
One announcement per turn
- Chose
- A separate status line for screen readers says “Checking your request…” when a turn starts, and the full reply once it’s done.
- Instead of
- Marking the reply itself as a live region, which is the default way to make streaming text accessible.
- Because
- With a live region on the reply, every step label and every streamed word is a new update: 25 for a single 20-word answer. That is noise, not access.
- Trade-off
- Screen-reader users hear the answer only when it’s complete. They can still read the conversation log at any time.
Live region on the reply0
One announcement per turn (shipped)0
Stop is always one key away
- Chose
- Esc, or the Send button turning into Stop, ends a reply mid-stream. A stopped reply is marked “Stopped. The reply above is incomplete.”
- Instead of
- Making people wait out an answer they no longer want.
- Because
- Agents are slow and sometimes head the wrong way. Stopping should be instant, and partial text should never look finished.
- Trade-off
- Every reply needs a “stopped” state, in the data and in the design, alongside working, streaming and done.
Honest about what it is, built for what it could be
- Chose
- The assistant runs Relay’s policy engine, ported to TypeScript, in the browser, and the page says “no AI model, nothing leaves the page”. The UI consumes a typed stream of events (step started, step finished, text, done).
- Instead of
- Pretending it is a model, or wiring up a paid model API for a portfolio piece.
- Because
- A deterministic engine is free and safe to host publicly, and it makes every outcome testable: all nine of Relay’s original test cases pass, and the UI tests assert exact replies. A model-backed stream can emit the same events, and the interface would not change.
- Trade-off
- It only understands what the rules understand. Ask without an order ID and it can only ask you for one.
04What review caught
Three things that surprised me
01 The step icons were cut in half
The steps open and close with a height animation, which clips anything outside the list. The status icons sat on the timeline line just outside the list’s edge, so mid-run they showed as half circles. Only a screenshot taken mid-run showed it; the finished state looked fine.
Fix: Drew the line inside the list, so the icons sit fully within the clipped area.
02 Adding “Assistant” pushed the theme switcher off phones
The header grew to 400 px on a 390 px screen, so the switcher was 10 px off the edge on every page, not just this one.
Fix: On phones the header keeps just the name and drops “Design engineer”, so the three links and the switcher fit.
03 Enter didn’t send while a reply was streaming
My review script typed a second message and pressed Enter before the first reply finished. Nothing was sent, and I first took it for a bug.
Fix: It was the behaviour I wanted: the draft is held, not lost, until the reply finishes or you press Esc to stop it. I kept it and the Send button turns into Stop while busy, so the state is visible.
05What’s next
What I would build next
- A real model behind the same events. Stream a model’s tool calls through the same interface and compare it with the rules on Relay’s nine test cases.
- Record every run into the Run explorer, so the two projects share real traces instead of sample data.