Architecting an AI Assistant into a Zero-Build Website
ChefAI is a conversational assistant for professional chefs, built for Unilever Food Solutions. It generates recipes, analyses menus and tailors its advice to the business asking. It runs in several markets and languages, including right-to-left ones. I led the frontend architecture for about four months, with a team that changed shape a few times along the way.
The awkward part was the platform. The site ships on Adobe Edge Delivery Services, which serves files straight out of the repository. No bundler, no transpile step, no framework runtime on the page. What you commit is what the browser runs. That is great for Core Web Vitals, and inconvenient when the feature you have been asked to build is an agentic chat client that wants long-lived connections, streaming state and a proper module graph.
This post is the list of decisions that got us there without adding a compile step. I have tried to write down what each one cost as well as what it bought, because the cost is usually the part that gets left out.
The constraints
There were three, and none of them had any give in it.
How the system ended up
Before the individual decisions, the overall shape. Three entry points share one widget core. The core talks to the agent platform over two channels that carry different things and live for different lengths of time. Identity, local state and configuration sit next to the core rather than inside it.
1. React as a feature's runtime, not the site's dependency
The chatbot needed component-shaped UI, but the site could not ship a framework. React is loaded from a CDN on demand, and an import map handles module aliases. This keeps it out of the site's default runtime, but browser-side module resolution weakens static analysis; we deliberately turned off one lint rule.
Inside the core, two rules did most of the work. Dependencies only point downward, so presentation never reaches for transport and transport never knows what a message bubble looks like. And a file that only re-exports other files is not allowed to exist. Wrapper modules are how a clean layering turns into a maze within six months. Both rules can be checked in a thirty-second review, which is the only reason they survived four months and a dozen contributors.
2. The client mints the correlation ID
The agent backend gives you two things: a request that eventually returns the final answer, and a separate event stream with the agent's intermediate reasoning. The obvious wiring is to send the message, get a run ID back, then subscribe to that run's stream. The timing is the problem: by the time the ID arrives, the first reasoning events have already been emitted before anything is listening.
So we flipped it. The client generates the run ID, opens the stream, and only then sends the message with that ID attached.
▲ SERVER OWNS THE ID — the listener arrives late
▼ CLIENT OWNS THE ID — the listener exists before the work
This avoided adding a replay buffer on the server or reconnect-and-catch-up logic on the client. The recommendations engine later reused the same pattern.
The transport choice came from the same place. The textbook client for server-sent events is EventSource, and we did not use it.
EventSource
- Cannot send custom headers, and every call here is authenticated
- Must be closed explicitly on unmount; no AbortSignal integration
- Reconnects automatically, on its own schedule
Streamed fetch
- Full control of headers and auth
- Abort signal wired to unmount, so closing the panel closes the stream
- We decide what "done" means
3. The stream is not the answer
The event stream does not carry the response. It carries the agent's reasoning: "checking your menu", "finding seasonal dishes", that sort of thing. The actual answer arrives separately. If you concatenate them into one text field, users see the model's scratchpad presented as professional advice, which is not a good look for a product aimed at chefs.
So a pending message has two channels with two lifetimes. Reasoning text is ephemeral. Each event replaces the previous one and none of it makes it into the transcript. Response text accumulates, and that is what gets persisted.
Looking at your menu…
For a spring menu, I'd start with…
Full answer with structured content attached
The same transport later served a second surface where the opposite was true. In the personalised onboarding flow, the reasoning is the content: it narrates progress while the agent builds a business profile. Same stream, two contracts, one client. That only worked because "what does this stream mean" was a parameter, not something baked into the UI.
4. Making a long wait easier to follow
Agent runs can take up to three minutes, and the answer lands as one payload at the end.
A block of text that appears all at once feels slower than text that arrives progressively, even when the wait is identical. So once the response is in, the interface re-streams it word by word, picking up from wherever the live reasoning stopped.
I was comfortable with this trade-off because it changed how completed text appeared without implying that tokens were arriving from the backend. A spinner for the whole wait gave people no indication of progress.
5. Degrade in tiers
Long-running AI calls fail in more ways than a CRUD request does. Rather than let each failure invent its own behaviour, we wrote the ladder down.
Two smaller choices hold this up. Every long request races an explicit timeout, so a hung agent turns into a real error instead of a spinner that never resolves. And thread resolution repairs itself rather than assuming the best: a stale thread gets replaced, a missing user gets recreated and the call retried once. Backend state drifts. Deployments happen, tokens expire, someone wipes a test environment. A client that assumes otherwise ends up showing users a dead end for somebody else's operational event.
6. Let identity build over time
We did not want to require registration before a chef could ask a first question. Instead, identity builds up over time.
Conversation history follows the same idea. It renders immediately from a local cache and revalidates against the network in the background. The cache is keyed by conversation, and that is not optional: a cache that shows the previous conversation for even one frame is not a performance bug, it is a privacy incident.
7. Keep environment configuration out of code
With no build step, endpoints and keys live in a table with one column per environment and are resolved when the module loads.
Changing an environment meant publishing content, not rebuilding or redeploying the site. It also moved a recurring release-day task to the delivery team, who were better placed to handle it.
The performance budget got the same treatment. The assistant is heavy: a view library, a markdown pipeline, a sanitiser, a handful of modules and stylesheets. None of it is on the critical path. It gets warmed during idle time on the one page where the next click is predictable.
8. A streaming surface still has to work for everyone
Streaming text and floating panels can easily exclude keyboard and screen reader users, so we treated accessibility as a constraint alongside the three at the top, not a pass at the end. That shaped decisions about the stream, modal and motion, with accessible and visual behaviour kept together to reduce drift during refactors.
aria-live="polite"throttled, not per token. A screen reader narrating every word is unusable, whatever the audit saysprefers-reduced-motionthe diagrams in this post follow the same rule as the productRight-to-left got the same treatment instead of a CSS toggle bolted on at the end. Direction is a property of the content, so a thread that mixes English and Arabic keeps each language ordered correctly without leaking direction into the surrounding chrome. Once you have done both, accessibility and internationalisation start to look like the same discipline. Both come down to not assuming the reader is you.
What it cost
Every one of these decisions has a bill attached. Here is ours.
The part that was leadership rather than architecture
Most of these decisions were cheap to make and expensive to keep. Under delivery pressure, the pull is always towards "just add a bundler", "just put the token where it is easy to reach", "just let this component call the API directly this once".
Across four months and a dozen contributors, I found it helped to keep the layering rules next to the code. In a review, we could check specific boundaries: no wrapper modules, dependencies point downward, and transport stays in one layer. When we made an exception, we recorded the reason so the next person could understand the decision.
What I would take to the next project
Choose the correlation ID before starting the work. When subscribing and starting work are separate calls, letting the client choose the ID avoids the race where early events arrive before the listener.
Keep backend inconsistencies in one place. Contracts drift. A defensive normalisation boundary kept the components simpler and meant a rename could be handled in one file. Duplicating that tolerance across the codebase would make changes harder to track.
Decide what the stream represents. The important question was not only how to read from a socket. An agent's reasoning and its answer are different kinds of text, with different lifetimes on screen. Getting that distinction wrong would undermine the experience regardless of the transport.