Architecting an AI Assistant into a Zero-Build Website

August 21, 2026

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.

4months, lead architect
0 KBframework on content pages
0build steps in the pipeline
3 minagent runs, made to feel instant

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.

No build stepplatform constraint
Core Web Vitals budgetbusiness constraint
Agentic, streaming UXproduct constraint
ChefAIevery decision below is downstream of these three
Three forces, no slack between them. Good architecture here wasn't about picking the best tool — it was about which constraint I refused to break.

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.

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
Hover any node. The two channels are the whole story — everything interesting in this system comes from refusing to treat them as one.

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

send messageserver mints IDresponse returnssubscribe✗ early reasoning lost

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

mint IDopen streamsend message with that ID✓ nothing missed
A change in causality, not in code volume. The correlation ID becomes an input to the request instead of an output of it — which removes the race rather than mitigating it.

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
The trade I accepted: hand-rolling the frame parsing that EventSource gives away free. Worth it — auth was not optional, and a stream that outlives its component is a leak waiting to be a bug report.

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.

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 lifecycle states. The transcript never grows a "thinking" artefact — and a user scrolling back tomorrow sees advice, not process.

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.

For a spring menu I'd lead with charred asparagus, then…
Presentation, not transport. I'm explicit about that distinction — and the seam is already the right shape for genuine token streaming the day the backend offers it.

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.

T1Full experience. Live reasoning, then the streamed answer.
T2Stream fails. Abort it, fall back to the plain request. No narration, full answer — most users never notice.
T3Request fails. The conversation survives; only the pending message degrades to something retryable.
Failure scoped to the smallest possible unit. A dead stream never costs the answer; a dead request never costs the conversation.

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.

The merge is the hard part, and the detail that mattered was ensuring it survives the redirect that immediately follows login. Get that wrong and you silently orphan the exact conversation that earned you the signup.

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.

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

What it cost

Architecture writing without a trade-off ledger is marketing. Mine:

We own a stream parser. Rejecting the built-in client bought authentication and lifecycle control, and permanently added protocol handling to our maintenance surface.
Progressive text is perception. Not real token streaming. Correct for the contract we had; I'd want it revisited the moment that contract changes.
Runtime module resolution. Import maps cost us static verification of imports. A deliberate trade, not an oversight.
Single-conversation cache. Simple and bounded, and wrong the day we ship multiple parallel threads. Known ceiling, documented.

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.