Hesham HassanProduct Engineer
← Writing

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.

4months as lead architect
0 KBframework JS on content pages
0build steps added
3 minlongest agent run we had to hide
The launch post from Unilever Food Solutions. Worth thirty seconds before the diagrams, so you know what the boxes are describing.

The constraints

There were three, and none of them had any give in it.

No build stepplatform constraint
Core Web Vitals budgetbusiness constraint
Agentic, streaming UXproduct constraint
ChefAIeverything below follows from these three
Most of what follows is what happens when you take all three seriously at the same time instead of quietly dropping one.

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.

SURFACEInline assistantauthored into any page
SURFACEFloating modalavailable site-wide
SURFACEGuided onboardingbusiness profiling flow
WIDGET CORE — loaded on demand
ui · presentationhooks · behaviourmodel · canonical message
CHANNEL 1 · REQUESTThe answerone payload, up to 3 min, cumulative
CHANNEL 2 · STREAMThe reasoningmany events, live, ephemeral
AGENT PLATFORM
chatthreadsusersbusinessrecommendations
IdentityAnonymous from the first message, merged into the account on login
Local stateConversation pointer plus a cache that renders before the network answers
ConfigurationEndpoints and keys authored as content, resolved per environment
The two channels are the important bit. Almost every decision below comes from keeping them apart rather than treating them as one response.

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

send message→server mints ID→response returns→subscribe→✗ early reasoning lost

▼ CLIENT OWNS THE ID — the listener exists before the work

mint ID→open stream→send message with that ID→✓ nothing missed
The ID is an input to the request instead of an output, so the listener is already open when the work starts.

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
The price is that we parse SSE frames ourselves, which EventSource would have done for us. Auth was not optional, and a stream that outlives its component is a leak waiting for a bug report, so I still think it was the right call.

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.

REASONING · replaced each event · never kept
Looking at your menu…
RESPONSE · accumulated
For a spring menu, I'd start with…
FINAL · recipes, products, follow-ups
Full answer with structured content attached
One message, three states. Someone scrolling back through yesterday's conversation sees the advice, not the process that produced it.

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.

For a spring menu I'd lead with charred asparagus, then…
This is presentation, not transport. The interface could support real token streaming later if the backend offers it.

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.

T1Full experience. Live reasoning, then the streamed answer.
T2Stream fails. Abort it and fall back to the plain request. No narration, full answer. Most users never notice.
T3Request fails. The conversation survives. Only the pending message becomes something you can retry.
A dead stream never costs you the answer. A dead request never costs you the conversation.

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.

The merge is the hard part. The detail that bit us was making sure it survives the redirect that follows login. Get that wrong and you orphan the exact conversation that earned you the signup.

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.

critical path
deferred
idle
prefetch assistant
on click
The expensive work happens while nobody is waiting. Opening the assistant feels instant, and a visitor who never opens it pays nothing for it.

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.

Three concrete commitments. Each one maps to something a chef using a screen reader or a keyboard would otherwise have hit on day one.

Right-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.

We own a stream parser. Skipping EventSource bought us auth and lifecycle control, and added protocol handling to our maintenance surface for good.
Progressive text is perception. Not real token streaming. Right for the contract we had. I would want it revisited the moment that contract changes.
Runtime module resolution. Import maps cost us static verification of imports. We knew that going in.
Single-conversation cache. Simple and bounded, and wrong the day we ship parallel threads. The ceiling is documented.
Throttled live regions need tuning. Too eager and the region spams. Too sparse and updates feel broken. The interval came out of user testing, not a guess.

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.