Architecture notes · ChefAI · Unilever Food Solutions
Seven decisions that put an AI agent inside a zero-build website
A production agentic assistant — live reasoning, persistent threads, merged anonymous identity — on a platform with no bundler, no framework runtime, and nothing to compile. Here is what I chose, and what each choice cost.
The brief, and why it was awkward
Build a conversational AI assistant for professional chefs. Recipe generation, menu analysis, business-aware personalisation. Multi-market, multi-language, right-to-left included.
Ship it on Adobe's Edge Delivery Services — a platform that serves files straight from the repository. No webpack. No Vite. No transpile step. What you commit is what the browser executes.
That's a gift for Core Web Vitals and a genuine problem for AI product work. Agentic chat wants long-lived connections, streaming state, component-shaped UI and a real module graph. I had to get all of it without introducing a compile step, because the moment you add one you've traded away the platform's entire reason for existing.
The shape it settled into
Before the decisions, the system. Three entry surfaces share one widget core; the core talks to the agent platform over two channels with deliberately different semantics; identity, local state and configuration sit alongside rather than inside.
1 · Treat the framework as a capability, not a dependency
The chatbot needed component-shaped UI. The website needed to ship no framework. Both are non-negotiable, so I stopped treating React as a dependency of the site and started treating it as a runtime capability of one feature.
React loads from a CDN, on demand, the first time a chat surface actually mounts — and never on a page where nobody opens the assistant. Module boundaries come from import maps declared once in the document head, which buys the refactor-proof aliasing that bundler config usually provides, with no tooling behind it.
Context
Rich UI needed; zero framework cost demanded on content pages.
Decision
Load the view library at runtime, scoped to the feature. Alias the module graph with import maps instead of a bundler.
Consequence
Content pages ship no framework at all. The price: module resolution moved to runtime, so static analysis got weaker and a lint rule had to be disabled deliberately.
Inside that core, two rules did most of the work. Dependencies only ever point downward — presentation never reaches for transport, transport never knows what a bubble looks like. And if a file only re-exports, it doesn't exist: wrapper modules are how clean layering becomes a maze six months later. Both are cheap enough to check in a thirty-second review, which is the only reason they survived four months and a dozen contributors.
2 · The client owns the correlation ID
This is the decision I'd defend hardest, and it's one line of consequence.
The agent backend offers two things: a request that eventually returns the final answer, and a separate event stream carrying the agent's intermediate reasoning. The obvious wiring — send the message, get back a run identifier, then subscribe to its stream — is also silently broken. By the time you know the ID, the first reasoning events have already fired into an empty room.
So I inverted ownership. The client mints the run ID, opens the stream, and only then sends the message.
▲ SERVER OWNS THE ID — the listener arrives late
▼ CLIENT OWNS THE ID — the listener exists before the work
It costs nothing. It needs no server-side replay buffer, no reconnect-and-catch-up logic, no "wait 200ms and hope". And once the pattern existed, the recommendations engine reused it unchanged.
The transport followed from the same instinct. The textbook choice for server-sent events is EventSource, and I rejected it:
EventSource
- Cannot send custom headers — and every call is authenticated
- No cancellation tied to a component lifecycle
- Automatic reconnect you can't reason about
Streamed fetch
- Full control of headers and auth
- Abort signal wired to unmount — closing the panel kills the stream
- We own the terminal condition, so "done" means one thing
3 · The stream is not the answer
The decision I'm most pleased with is an architecture call wearing UX clothing.
The event stream doesn't carry the response. It carries the agent's reasoning — "checking your menu", "finding seasonal dishes". The response arrives separately. Treating them as one text field would have shown users the model's scratchpad as if it were professional advice.
So the pending message has two channels with two different lifetimes. Reasoning text is ephemeral: each event replaces the last, and none of it survives into the transcript. Response text is cumulative, and is what finally persists.
Looking at your menu…
For a spring menu, I'd start with…
Full answer with structured content attached
The same transport serves a second product surface where the opposite is true: in the personalised onboarding flow, the agent's reasoning is the content, narrating progress while a business profile is built. Same stream, two contracts, one client. That reuse was only possible because the channel semantics were a parameter rather than an assumption baked into the UI.
4 · Perceived latency is an architectural concern
Agent runs can take up to three minutes. The response arrives as a single payload at the end.
A payload that lands all at once feels slower than text that arrives progressively — the same millisecond of waiting, a completely different experience. So the interface re-streams the finished response word by word, resuming from wherever the live reasoning left off.
I'll defend the honesty of this: it isn't faking capability, it's matching the interface to how people read. The alternative was a three-minute spinner, which is a worse lie.
5 · Degrade in tiers, never in place
Long-running AI calls fail in more ways than CRUD does. I made the ladder explicit rather than letting each failure invent its own behaviour.
Two supporting choices make it hold. Every long request races an explicit timeout, so a hung agent surfaces as a real error instead of a spinner that never resolves. And thread resolution is self-healing rather than optimistic: stale threads are replaced, a missing user is recreated and retried once. Backend state drifts — deployments, expiries, wiped environments — and a client that assumes otherwise shows users a dead end for someone else's operational event.
6 · Identity is progressive
Asking a chef to register before their first question would have killed the funnel. So identity accrues instead of gating.
Conversation history follows the same "value first" principle: it renders instantly from a local cache and revalidates against the network in the background. The load-bearing detail is that the cache is keyed by conversation — because a cache that can serve the previous conversation for even one frame isn't a performance bug, it reads as a privacy incident.
7 · Configuration is content, not code
On a platform with no build step, environment configuration has no business living in code. Endpoints and keys are authored in a table with a column per environment, resolved when the module loads.
Repointing an environment is a content publish. No rebuild, no redeploy, no engineer in the loop — which mattered more than it sounds, because it turned a recurring release-day dependency into something the delivery team handled themselves.
The same instinct governed the performance budget. The assistant is heavy: a view library, a markdown pipeline, a sanitiser, several modules and stylesheets. None of it sits in the critical path. It's warmed during idle time on the one page where the next click is predictable.
What it cost
Architecture writing without a trade-off ledger is marketing. Mine:
The part that was leadership, not architecture
Most of these decisions were cheap to make and expensive to hold. Under delivery pressure, the pull is always toward "just add a bundler", "just put the token where it's easy", "just let this component call the API directly".
Three things kept the design intact across four months and a dozen contributors. I wrote the layering rules down where the code lives, so review had something to point at rather than an opinion to defend. I made the boundaries cheap to check — no wrapper modules, dependencies point downward, transport stays in one layer are rules you can verify in seconds. And when a constraint got broken for good reason, we recorded why, so the next person inherited a decision instead of a mystery.
Architecture that only exists in the architect's head isn't architecture. It's a preference.
Three things that transfer
Own your correlation IDs. Whenever subscribing and starting work are separate calls, let the client decide the identity. It converts a race condition into a non-event for the price of one function.
Put the ugliness where it can be deleted. Backend contracts drift. One deliberately defensive normalisation boundary kept every component clean and made every rename a one-file change. The same tolerance spread across twelve files is a disease.
Streaming is a UX contract before it's a transport one. The hard question was never how to read from a socket. It was recognising that an agent's reasoning and an agent's answer are different kinds of text that deserve different lifetimes on screen. Get that wrong and no amount of transport correctness saves the experience.